Catalisa.Building Blocks
Catálogo/Plataforma/CMS

CMS

Alpha

Cor, texto e aviso do aplicativo mudam hoje, sem passar pela loja

42
Endpoints
12
Entidades
0
Provedores
Tenant
Escopo
3037
Porta
2026-09
Desde

O time de produto muda a cor, o texto, a promoção e o aviso do aplicativo no mesmo dia, sem release e sem esperar a base atualizar — e cada publicação sai assinada, com quem mudou o quê registrado e a medição de cada peça.

Para quem é
  • Bancos e fintechs com aplicativo white-label que precisam trocar marca e copy por cliente sem uma build por cliente
  • Times de produto que hoje esperam o ciclo da loja para corrigir um texto ou tirar uma promoção do ar
  • Áreas de risco e jurídico que precisam aprovar a copy de segurança antes de ela chegar ao cliente
Substitui
  • Remote config genérico: um JSON solto sem tipo, sem revisão e sem trilha
  • WebView com HTML remoto dentro do app, que é superfície de ataque num app bancário
  • CMS de site (Contentful, Strapi) adaptado na marra para conteúdo de aplicativo nativo
O que não é
  • Um construtor de telas — o app já sabe desenhar; o bloco diz o quê aparece, onde e para quem
  • Um servidor de páginas HTML ou um site
  • Uma ferramenta de push ou de mensagem individual para o cliente
O que dá para fazer

45 endpoints em 7 recursos.

Explorar a API →
01

Resumo executivo

O CMS guarda o que o seu aplicativo mostra — cor, logo, texto, aviso, promoção — e entrega isso ao app pronto para usar, sem que ninguém precise publicar uma versão nova na loja.

Na prática: a área de produto tira do ar a promoção que acabou às nove da manhã de uma terça, e o cliente vê a mudança na próxima vez que abrir o app. Sem release, sem esperar a base atualizar, sem pedir nada para o time de mobile.

O bloco nasceu para o aplicativo white-label de banco, onde o mesmo app roda com a marca de vários clientes, e onde conteúdo remoto mal resolvido é risco: HTML injetado numa WebView e link arbitrário dentro do app são exatamente como se monta um golpe com a cara da instituição. Por isso o conteúdo aqui é tipado, as ações vêm de uma lista declarada pelo produto e cada publicação sai assinada. Está em alpha desde setembro de 2026.

Desde o BB-6 o menu do app também é conteúdo: a barra de cinco itens e o menu de página são editados aqui — mostrar, esconder, reordenar, criar item que abre uma página de conteúdo — e cada item pode apontar para uma chave de capacidade do feature-flags. Chave off, o item some; maintenance, o item fica, bloqueado, com o aviso escrito pelo time. O cms mostra; quem decide é o feature-flags, e quem recusa a operação é o bloco dono dela.

CampoValor
idcms
CategoriaPlataforma
Escopotenant (organização do token)
Porta (standalone)3037
Alias@cms
Statusalpha

02

O problema

negócio

O cenário. Uma instituição lança o aplicativo. A partir daí, tudo que é texto, cor e peça visual dentro dele passa a ser refém do ciclo de release: escrever, revisar, buildar, submeter à loja, esperar a revisão, esperar a base atualizar.

O que trava hoje:

  • Corrigir uma palavra custa uma versão. Um aviso ambíguo na tela de Pix fica no ar por dias.
  • Campanha tem data, release não. A promoção que acaba domingo só sai do app na quarta.
  • White-label multiplica builds. Cada cliente com sua cor e seu logo vira uma variante de build para compilar, assinar e manter.
  • A saída improvisada é pior que o problema. remoteConfig com JSON solto, ou uma WebView apontando para uma URL que alguém edita — num app bancário, isso é superfície de ataque sem dono.
  • Ninguém sabe quem mudou o quê. O texto mudou, o time descobre pelo suporte, e não há trilha.

O custo de não resolver. Cada correção de copy vira um ciclo de loja — dias entre o problema identificado e o cliente atendido, com a agravante de que, mesmo depois de publicada, a versão só alcança a base conforme as pessoas atualizam. A alternativa improvisada troca esse custo por risco regulatório e de fraude, que é pior de explicar depois.


03

Proposta de valor

negócio
AntesDepois
Texto errado espera o próximo releaseTexto corrigido e publicado no mesmo dia
Uma build por marca de clienteUm app, um tema por espaço, publicado por quem opera a marca
Conteúdo remoto em JSON livre, validado no appConteúdo tipado, validado no servidor, recusado na escrita
"Quem mudou esse aviso?" sem respostaEvento de trilha por mudança, com ator e hash
Campanha medida por achismoImpressão, clique e CTR por peça e por variante
Tirar um item do menu exige buildMenu editado como conteúdo, publicado e assinado com o resto
Pix fora do ar "some" e o cliente acha que foi bloqueadoEstado maintenance: o item fica, explica o motivo e o que segue funcionando

Publicação em minutos, não em semanas. Editar, publicar, e o app pega na próxima abertura — com ETag e 304 para não gastar dado e bateria de quem já está em dia.

Seguro por construção, não por revisão. O que pode existir no conteúdo é o catálogo fechado de componentes; o que uma ação pode fazer é o que o produto declarou. Nenhuma das duas coisas depende de alguém lembrar de revisar.

Marca por cliente sem build por cliente. Tema com tokens de cor, dark mode, fontes de allowlist e logo — validado no contraste antes de chegar ao aparelho.

Copy de segurança com quatro olhos. Chave protegida só muda com aprovação de um segundo usuário.

Cada peça medida. Impressão, visualização, clique, dispensa e conversão por entrada, variante e posição — de forma pseudônima, sem dado pessoal.


04

Casos de uso reais

negócio

Caso 1 — A promoção sai do ar na hora em que acabou Cenário ilustrativo

Contexto

Banco digital com aplicativo próprio, campanha de portabilidade de salário com prazo fechado no domingo.

A dor

A campanha acabava no domingo e o banner continuava no app até a build seguinte, na quarta. Cliente entrava, pedia a oferta, e o atendimento explicava que tinha acabado.

A solução com o BB

A peça é uma entrada promo no slot home.destaque, com schedule.until no domingo às 23h59. O app resolve a agenda localmente pelo manifesto que já tem.

O resultado

Zero peça expirada no ar, e a régua da campanha deixa de depender do calendário de release.

Caso 2 — O mesmo aplicativo com a marca de quatro instituições Cenário ilustrativo

Contexto

Plataforma white-label que entrega o mesmo app para quatro clientes.

A dor

Cada marca exigia uma variante de build — cor, logo, textos — e cada ajuste de identidade visual virava uma fila com o time de mobile.

A solução com o BB

Um espaço por cliente, com theme (tokens, dark mode, fontes, logo) e o catálogo de strings. O app lê o manifesto do espaço que lhe cabe e monta a própria identidade.

O resultado

Uma build, quatro marcas, e a troca de identidade passa a ser operação de quem cuida da marca, não de engenharia.

Caso 3 — O aviso do Pix não muda sem dois pares de olhos Cenário ilustrativo

Contexto

Instituição com copy de segurança revisada pelo jurídico na tela de confirmação de Pix.

A dor

O texto estava no código. Mudar exigia PR, e o jurídico revisava por captura de tela num grupo — sem registro de quem aprovou o quê.

A solução com o BB

A chave pix.review.newRecipientWarning é marcada protected, com regra requiredVariables: ["{valor}"]. Uma mudança vira solicitação; só um usuário com CMS_SECURITY_APPROVER, diferente de quem propôs, libera. Tudo vira evento no audit-trail.

O resultado

A copy sensível passa a ter aprovação formal e rastreável, e o release publicado com uma solicitação pendente sai com o texto anterior, nunca com o proposto.

Caso 4 — Duas versões do mesmo aviso, medidas Cenário ilustrativo

Contexto

Time de produto quer saber qual chamada leva mais gente a completar o cadastro biométrico.

A dor

Testar exigia feature flag no app e um release por hipótese.

A solução com o BB

Uma entrada com duas variants de peso 50. O app escolhe pela semente estável (anonymousId) e manda impression/click para a rota de eventos. O editor lê o CTR por variante em GET /analytics.

O resultado

Hipótese testada sem release, com a mesma pessoa vendo sempre a mesma variante.


05

Mercado e diferenciais

negócio

Panorama. Hoje o problema é resolvido por um de três caminhos: um CMS de site (Contentful, Strapi) adaptado para app, um remote config de pares chave/valor (Firebase), ou uma ferramenta de marketing (Braze). O primeiro entrega conteúdo livre e joga a validação para o app; o segundo não tem tipo nem revisão; o terceiro é ótimo para campanha e alheio à copy de produto e de segurança.

CritérioCatalisa CMSContentfulFirebase Remote ConfigBraze
Componentes nativos tipados e fechadosSim, catálogo em códigoNão (modelo livre)NãoParcial (cards próprios)
Ação restrita a allowlist do produtoSimNãoNãoNão
Release imutável assinado (Ed25519)SimNãoNãoNão
Aprovação por quatro olhos na copy sensívelSim, permissão própriaFluxo de publicação por planoNãoNão
Analytics por peça, pseudônimoSimNãoParcial (via Analytics)Sim, com perfil identificado
Multi-tenant nativo com isolamento por organizaçãoSimPor espaço/contaPor projetoPor workspace
Editor visual maduroNão (API primeiro)SimSimSim

Nossos diferenciais. (1) O catálogo fechado é o que permite prometer que conteúdo remoto não vira execução dentro do app — difícil de copiar num produto genérico, porque o valor dele é justamente aceitar qualquer forma. (2) O allowlist de rotas e parâmetros transforma "o conteúdo não pode carregar dado de transação" em impossibilidade estrutural, não em política escrita. (3) A assinatura por organização dá ao app uma verificação que não depende de confiar na rede nem em nós. (4) Os quatro olhos vivem no servidor, com permissão separada — o que sobrevive a uma auditoria.

Quando escolher o concorrente. Se você precisa de um editor visual rico hoje, com pré-visualização lado a lado, biblioteca de mídia e fluxo editorial de redação, o Contentful entrega isso pronto e nós não — nosso editor é API primeiro, e a tela é do produto que integra. Se o seu conteúdo é de site e não de aplicativo, também não é aqui. E se o que você quer é orquestração de campanha com segmentação comportamental sobre perfil identificado, o Braze faz isso melhor — e nosso analytics é pseudônimo por decisão, não por limitação.


06

Modelo de cobrança e ROI

negócio

Precificação em definição. A unidade natural é o espaço publicado por mês — um aplicativo ou site de uma organização —, porque é a unidade que o cliente entende e que corresponde ao trabalho de operar marca, copy e campanha.

O que dispara custo:

DriverPor que importa
Espaços ativosCada app/site é um conjunto próprio de tema, strings e entradas
Releases publicadosPublicação resolve, hasheia, assina e guarda o manifesto inteiro
Leituras de manifestoTráfego do app; mitigado por ETag/304
Eventos de analyticsIngestão, dedupe e agregação diária
Assets servidosBytes lidos do file-storage para hash e redirecionamento

ROI, a conta de guardanapo. O custo evitado não é o da ferramenta, é o do ciclo: cada correção de texto que hoje exige release consome trabalho de mobile, QA e publicação, e ainda demora dias para alcançar a base. Uma operação que faz meia dúzia dessas por mês já paga um bloco de conteúdo — e isso antes de contar a campanha que ficou no ar depois do prazo.


07

Arquitetura

flowchart LR
  subgraph Editor
    E[Produto / Marketing / Jurídico]
  end
  subgraph CMS["Building Block cms (porta 3037)"]
    API["/cms/api/v1 — JWT + permissão"]
    DRAFT[(Rascunho: tema, strings, entradas)]
    REL["Release: manifesto + sha256 + Ed25519"]
    DEL["/cms/delivery/v1 — chave de entrega"]
  end
  subgraph Fora
    FS[file-storage]
    AUD[audit-trail]
    FF[feature-flags]
    APP[Aplicativo]
  end

  E -->|edita| API --> DRAFT
  E -->|publica| REL
  DRAFT --> REL
  REL -->|sha256 do asset| FS
  REL -->|eventos cms.*| AUD
  APP -->|GET manifest + If-None-Match| DEL
  DEL -->|manifesto assinado ou 304| APP
  APP -->|POST capabilities| DEL
  DEL -->|estado das chaves, pela fachada| FF
  APP -->|POST events| DEL
  APP -->|verifica assinatura| APP

Por que duas portas. /api/v1 é do editor e aceita JWT ou chave de API da organização. /delivery/v1 é do aplicativo e aceita apenas a chave de entrega, somente leitura e escopada num espaço. Elas não compartilham autenticação por construção: credencial de editor não desce para o aparelho de um cliente.

Por que o release é imutável. Publicar resolve tudo agora — tema, strings aprovadas, entradas habilitadas, assets com hash — e grava. Assim a pergunta "o que o app mostrava naquele dia" se responde pelo dado, não pelo log; e o ETag pode ser o próprio sha256, porque o conteúdo daquela versão nunca muda.

Por que o endereço do asset é estável. URL pré-assinada de S3 expira. Um manifesto imutável e assinado não pode carregar endereço que morre em uma hora — então ele guarda o fileId e o sha256, e o endereço aponta para /cms/delivery/v1/spaces/:key/assets/:fileId, que redireciona para uma URL fresca a cada leitura.

Por que a resolução é do cliente. Audiência, agenda e variante são resolvidas no app, sobre o manifesto que ele já tem. Isso mantém o app funcionando offline e evita mandar atributo de cliente para o servidor a cada abertura. A função que faz isso (resolveForClient) é pura e exportada, para que servidor e SDK não tenham duas implementações divergindo em silêncio. O menu segue a mesma regra: resolveNavigation(manifest, { capabilities }) monta a barra e o menu a partir do manifesto e do último estado de capacidade que o app conhece.

Por que o estado da capacidade não mora aqui. O manifesto carrega navigation e capabilities (as chaves que o menu referencia), mas não o estado delas — estado é o que muda quando alguém põe o Pix em manutenção, e um release imutável não pode carregá-lo. O app pergunta em POST /delivery/v1/spaces/:key/capabilities; o cms repassa ao feature-flags pela fachada e traduz a chave de texto do aviso nas strings do release. Esconder não é desligar: quem recusa a operação de fato é o bloco dono dela, pelo portão do feature-flags (README de lá, §7).

Monolito e standalone. Em monolito o bloco é resolvido pelo container TypeDI; em standalone — o modo de produção — sobe como serviço próprio na porta 3037 e usa a fachada remota do file-storage. Sem MODULE_FILE_STORAGE_URL configurado, publicar um espaço que tem asset falha com asset.unreadable.


08

Conceitos e modelo de dados

Glossário

TermoDefinição
Espaço (space)Um aplicativo ou site de uma organização. Unidade de publicação.
Slot (placement)Posição que o app expõe (home.top), com os tipos que aceita.
Rota internaDestino que o app sabe abrir, declarado com os parâmetros seguros.
Entrada (entry)Uma instância de componente (ou de tipo customizado) num slot.
VarianteVersão alternativa da entrada, com peso, para A/B.
Tipo customizadoEstrutura de conteúdo do produto, descrita por schema restrito.
String protegidaChave de copy sensível cuja mudança exige aprovação de outro usuário.
ReleaseFotografia imutável e assinada do rascunho inteiro.
ManifestoO documento resolvido que o app aplica.
Chave de entregaCredencial somente-leitura do app, escopada num espaço.
Item de navegaçãoItem da barra (tabbar, até 5 visíveis) ou do menu (menu): aponta para rota do allowlist ou para page:<entryId>.
CapacidadeChave do feature-flags (pix.send) com três estados: on, maintenance, off. O item de navegação a referencia; o cms só exibe.

Modelo de dados (schema Postgres cms)

ModeloTabelaPropósitoCampos-chave
CmsSpacecms_spacesO espaço e seus contratoskey, locales, allowedExternalHosts, internalRoutes, slots
CmsContentTypecms_content_typesTipo customizado do produtokey, schema, version
CmsEntrycms_entriesConteúdo em um slottype, placement, fields, audience, schedule, variants
CmsThemecms_themesMarca do espaçotokens, dark, fonts, radius, logoAssetId
CmsStringcms_stringsTexto por chave e localekey, locale, value, protected, rules
CmsStringApprovalcms_string_approvalsQuatro olhos da copy protegidaproposedValue, status, requestedBy, decidedBy
CmsReleasecms_releasesRelease imutávelversion, manifest, sha256, signature, keyId
CmsSigningKeycms_signing_keysChave Ed25519 da organizaçãokeyId, publicKeyPem, privateKeyCipher
CmsDeliveryKeycms_delivery_keysCredencial do appprefix, keyHash, revokedAt
CmsEventcms_eventsEvento cru pseudônimoclientEventId, type, entryId, anonymousId
CmsEventDailycms_event_dailyAgregado diárioday, entryId, variant, placement, type, count
CmsNavigationItemcms_navigation_itemsMenu do app, por espaçokey, placement, order, visible, label, icon, capability
CmsLegalDocumentcms_legal_documentsDocumento legal do app (termos, privacidade, biometria) — o rascunho, um por chavekey, version, title, body (markdown), required

Ciclo de vida de uma mudança

stateDiagram-v2
  [*] --> Rascunho: editor cria ou altera
  Rascunho --> PendenteAprovacao: chave protegida alterada
  PendenteAprovacao --> Rascunho: aprovado por outro usuário
  PendenteAprovacao --> Rascunho: rejeitado (valor anterior fica)
  Rascunho --> Release: publicar (sha256 + assinatura)
  Release --> Release: rollback gera versão NOVA
  Release --> [*]

Estado da solicitação de aprovação

stateDiagram-v2
  [*] --> PENDING
  PENDING --> APPROVED: CMS_SECURITY_APPROVER ≠ autor
  PENDING --> REJECTED: com motivo
  APPROVED --> [*]
  REJECTED --> [*]

09

Referência da API

Prefixo: /cms. Autenticação do editor: JWT ou X-API-Key (normalizados em c.get('user')); todas as rotas de /api/v1 exigem contexto de organização no token.

Catálogo e espaços

MétodoRotaDescriçãoPermissão
GET/cms/api/v1/catalogCatálogo fechado de componentes e sua versãoCMS_READ
GET/cms/api/v1/spacesLista os espaços da organizaçãoCMS_READ
POST/cms/api/v1/spacesCria um espaçoCMS_EDITOR
GET/cms/api/v1/spaces/:keyUm espaçoCMS_READ
PATCH/cms/api/v1/spaces/:keyAtualiza nome, locales, allowlists e slotsCMS_EDITOR

POST /cms/api/v1/spaces

json
{
  "key": "banco-app",
  "name": "App do banco",
  "locales": ["pt-BR", "en-US"],
  "allowedExternalHosts": ["ajuda.banco.com.br"],
  "internalRoutes": [
    { "key": "extrato", "params": [{ "name": "periodo", "type": "enum", "values": ["30d", "90d"] }] }
  ],
  "slots": [{ "key": "home.top", "allowedTypes": ["ribbon", "alert"], "maxItems": 1 }]
}
{
  "key": "banco-app",
  "name": "App do banco",
  "locales": ["pt-BR", "en-US"],
  "allowedExternalHosts": ["ajuda.banco.com.br"],
  "internalRoutes": [
    { "key": "extrato", "params": [{ "name": "periodo", "type": "enum", "values": ["30d", "90d"] }] }
  ],
  "slots": [{ "key": "home.top", "allowedTypes": ["ribbon", "alert"], "maxItems": 1 }]
}

Erros: 409 CONFLICT (chave repetida), 400 VALIDATION com details.reason = "slot.type_unknown".

Conteúdo

MétodoRotaDescriçãoPermissão
GET/cms/api/v1/spaces/:key/content-typesTipos customizados do espaçoCMS_READ
POST/cms/api/v1/spaces/:key/content-typesCria ou versiona um tipoCMS_EDITOR
GET/cms/api/v1/spaces/:key/entriesLista entradas (?placement=, ?type=)CMS_READ
POST/cms/api/v1/spaces/:key/entriesCria entradaCMS_EDITOR
GET/cms/api/v1/spaces/:key/entries/:idUma entradaCMS_READ
PATCH/cms/api/v1/spaces/:key/entries/:idAltera entradaCMS_EDITOR
DELETE/cms/api/v1/spaces/:key/entries/:idRemove (soft delete)CMS_EDITOR

POST /cms/api/v1/spaces/:key/entries

json
{
  "key": "aviso-manutencao",
  "type": "alert",
  "placement": "home.top",
  "priority": 10,
  "frequencyCap": "daily",
  "audience": [{ "attribute": "segmento", "op": "in", "values": ["PJ"] }],
  "schedule": { "from": "2026-10-01T00:00:00.000Z", "until": "2026-10-07T23:59:59.000Z", "timezone": "America/Sao_Paulo" },
  "fields": {
    "pt-BR": {
      "title": "Manutenção programada",
      "text": "O app fica indisponível entre 2h e 4h.",
      "tone": "warning",
      "cta": { "label": "Ver detalhes", "action": { "kind": "internal", "route": "extrato", "params": { "periodo": "30d" } } }
    }
  }
}
{
  "key": "aviso-manutencao",
  "type": "alert",
  "placement": "home.top",
  "priority": 10,
  "frequencyCap": "daily",
  "audience": [{ "attribute": "segmento", "op": "in", "values": ["PJ"] }],
  "schedule": { "from": "2026-10-01T00:00:00.000Z", "until": "2026-10-07T23:59:59.000Z", "timezone": "America/Sao_Paulo" },
  "fields": {
    "pt-BR": {
      "title": "Manutenção programada",
      "text": "O app fica indisponível entre 2h e 4h.",
      "tone": "warning",
      "cta": { "label": "Ver detalhes", "action": { "kind": "internal", "route": "extrato", "params": { "periodo": "30d" } } }
    }
  }
}

Erros possíveis, todos 400 VALIDATION com details.reason: placement.unknown, placement.type_not_allowed, type.unknown, locale.unknown, text.markup_not_allowed, richtext.image_not_allowed, action.route_not_allowed, action.param_not_declared, action.param_value, action.scheme_not_https, action.host_not_allowed, variant.weight, entry.duplicate.

MétodoRotaDescriçãoPermissão
GET/cms/api/v1/spaces/:key/navigationItens do menu, ordenadosCMS_READ
PUT/cms/api/v1/spaces/:key/navigationSubstitui o menu inteiro (até 40 itens)CMS_EDITOR

PUT /cms/api/v1/spaces/:key/navigation

json
{
  "items": [
    { "key": "home", "placement": "tabbar", "order": 0, "visible": true, "label": { "pt-BR": "Início" }, "icon": "home" },
    { "key": "pix", "placement": "tabbar", "order": 1, "visible": true, "label": { "pt-BR": "Pix" }, "icon": "send", "capability": "pix.send" },
    { "key": "page:5f1c…", "placement": "menu", "order": 0, "visible": true, "label": { "pt-BR": "Ajuda" }, "icon": "circle-help" }
  ]
}
{
  "items": [
    { "key": "home", "placement": "tabbar", "order": 0, "visible": true, "label": { "pt-BR": "Início" }, "icon": "home" },
    { "key": "pix", "placement": "tabbar", "order": 1, "visible": true, "label": { "pt-BR": "Pix" }, "icon": "send", "capability": "pix.send" },
    { "key": "page:5f1c…", "placement": "menu", "order": 0, "visible": true, "label": { "pt-BR": "Ajuda" }, "icon": "circle-help" }
  ]
}

O conjunto é validado inteiro e recusado inteiro: 400 VALIDATION com details.reason do primeiro problema e details.problems[] com todos — navigation.route_not_allowed (rota fora de internalRoutes), navigation.page_not_found (page:<id> sem entrada viva de tipo página), navigation.page_invalid (o sufixo de page: não é uuid), navigation.icon_unknown (não é nome lucide em kebab-case — emoji cai aqui), navigation.tabbar_overflow (mais de 5 visíveis na barra), navigation.label_missing, navigation.duplicate, locale.unknown.

Documentos legais

MétodoRotaDescriçãoPermissão
GET/cms/api/v1/spaces/:key/legal-documentsRascunhos, cada um com published (a versão que o app tem)CMS_READ
PUT/cms/api/v1/spaces/:key/legal-documents/:docKeyGrava o rascunho de um documentoCMS_EDITOR
DELETE/cms/api/v1/spaces/:key/legal-documents/:docKeyTira do rascunho (sai do app no próximo release)CMS_EDITOR

PUT /cms/api/v1/spaces/:key/legal-documents/doc_conta_2026_09

json
{ "version": "2026.10", "title": "Termos de uso", "body": "Markdown sem HTML…", "required": true }
{ "version": "2026.10", "title": "Termos de uso", "body": "Markdown sem HTML…", "required": true }

O release fotografa os documentos no manifesto, em legalDocuments: [{ key, version, title, body, required, publishedAt }], ordenados por key. publishedAt é quando aquela versão entrou num release pela primeira vez. O banking lê dali, serve o texto ao app (getLegalDocument) e registra o aceite pelo hash de "<versão>\n<texto>": versão nova obriga o cliente a aceitar de novo.

A regra que protege o aceite: texto aceito nunca muda debaixo dele. 409 CONFLICT com details.reason: legal.version_unchanged (o texto mudou e a versão não) e legal.version_published (essa versão já foi ao app com outro texto, mesmo que o documento tenha sido apagado e recriado). O título pode mudar sem versão nova. Também 400 VALIDATION: legal.invalid (forma; a trilha leva só os caminhos dos campos), action.host_not_allowed/action.scheme_not_https (link do texto fora do allowlist do espaço — a mesma régua do texto rico, reaplicada no release como legal.link_not_allowed) e legal.too_many (teto de 20 documentos por espaço). Toda recusa vai para a trilha (cms.legal_document.refused). Rollback não volta o texto jurídico: o release novo leva os documentos do release corrente e avisa legal.kept, como a copy protegida.

Trilha: cms.legal_document.updated / .deleted / .refused no rascunho; cms.legal_document.published (um por documento novo ou versão nova, com bodySha256, releaseVersion e publishedAt) e cms.legal_document.withdrawn no release.

Tema e strings

MétodoRotaDescriçãoPermissão
GET/cms/api/v1/spaces/:key/themeTema, tokens derivados e avisos de contrasteCMS_READ
PUT/cms/api/v1/spaces/:key/themeGrava o temaCMS_EDITOR
GET/cms/api/v1/spaces/:key/stringsStrings (?locale=)CMS_READ
PUT/cms/api/v1/spaces/:key/strings/:localeGrava em lote (até 500) — só chave e valorCMS_EDITOR
PUT/cms/api/v1/spaces/:key/strings/:locale/protectionLiga/desliga a proteção e define a regraCMS_SECURITY_APPROVER
GET/cms/api/v1/spaces/:key/approvalsSolicitações pendentesCMS_READ
POST/cms/api/v1/spaces/:key/approvals/:id/approveAprova (autor ≠ aprovador)CMS_SECURITY_APPROVER
POST/cms/api/v1/spaces/:key/approvals/:id/rejectRejeita com motivoCMS_SECURITY_APPROVER

A resposta de PUT /theme traz derived (onPrimary, onPrimaryDark) e warnings[] — ver §14.

Releases, preview e chaves

MétodoRotaDescriçãoPermissão
GET/cms/api/v1/spaces/:key/releasesHistóricoCMS_READ
POST/cms/api/v1/spaces/:key/releasesPublicaCMS_PUBLISHER
GET/cms/api/v1/spaces/:key/releases/currentRelease corrente com manifestoCMS_READ
GET/cms/api/v1/spaces/:key/releases/:versionUm releaseCMS_READ
POST/cms/api/v1/spaces/:key/releases/:version/rollbackRepublica como versão novaCMS_PUBLISHER
POST/cms/api/v1/spaces/:key/preview-tokensToken de preview (1–60 min), devolve jtiCMS_EDITOR
DELETE/cms/api/v1/spaces/:key/preview-tokens/:jtiRevoga o token antes do expCMS_EDITOR
GET/cms/api/v1/spaces/:key/delivery-keysChaves do app (sem o segredo)CMS_READ
POST/cms/api/v1/spaces/:key/delivery-keysEmite chave (segredo sai uma vez)CMS_PUBLISHER
DELETE/cms/api/v1/spaces/:key/delivery-keys/:idRevogaCMS_PUBLISHER
GET/cms/api/v1/signing-keysChaves públicas da organizaçãoCMS_READ
POST/cms/api/v1/signing-keys/rotateRotaciona a chave de assinaturaCMS_PUBLISHER
GET/cms/api/v1/spaces/:key/analyticsContagens e CTR (?entryId=&from=&to=)CMS_READ

Entrega (o aplicativo)

Autenticação: X-CMS-Delivery-Key: cmsd_... (ou Authorization: Bearer cmsd_...).

MétodoRotaDescrição
GET/cms/delivery/v1/spaces/:key/manifestManifesto assinado; ETag forte e 304
POST/cms/delivery/v1/spaces/:key/capabilitiesEstado das chaves de manifest.capabilities, com o aviso resolvido; no-store
GET/cms/delivery/v1/keysChaves públicas da organização da chave apresentada
GET/cms/delivery/v1/spaces/:key/keysO mesmo, com o espaço no caminho
GET/cms/delivery/v1/spaces/:key/assets/:fileIdRedireciona (302) para a URL fresca do asset
GET/cms/delivery/v1/spaces/:key/previewRascunho, com X-CMS-Preview-Token
POST/cms/delivery/v1/spaces/:key/eventsLote de até 100 eventos (202)

GET manifest — resposta

json
{
  "manifest": { "spaceKey": "banco-app", "version": 8, "catalogVersion": 1, "theme": {}, "strings": {}, "entries": [], "assets": [], "navigation": [], "capabilities": ["pix.send"] },
  "version": 8,
  "sha256": "9f2c…",
  "keyId": "cms-3f7a1c22-m1k2-9ab3",
  "signature": "base64…",
  "signedAt": "2026-09-22T12:00:00.000Z",
  "publishedAt": "2026-09-22T12:00:00.000Z"
}
{
  "manifest": { "spaceKey": "banco-app", "version": 8, "catalogVersion": 1, "theme": {}, "strings": {}, "entries": [], "assets": [], "navigation": [], "capabilities": ["pix.send"] },
  "version": 8,
  "sha256": "9f2c…",
  "keyId": "cms-3f7a1c22-m1k2-9ab3",
  "signature": "base64…",
  "signedAt": "2026-09-22T12:00:00.000Z",
  "publishedAt": "2026-09-22T12:00:00.000Z"
}

401 sem chave válida para aquele espaço · 404 com details.reason = "release.none" quando o espaço ainda não publicou · 304 sem corpo quando o If-None-Match casa com o sha256.

POST capabilities — pedido e resposta

json
{ "userId": "device-7f3a", "locale": "pt-BR", "attributes": { "segmento": "PJ" } }
{ "userId": "device-7f3a", "locale": "pt-BR", "attributes": { "segmento": "PJ" } }
json
{
  "version": 8,
  "locale": "pt-BR",
  "capabilities": {
    "pix.send": { "state": "maintenance", "reason": "MAINTENANCE", "message": "Pix em manutenção com o banco parceiro. Saldo e extrato seguem normais." },
    "products.receivables": { "state": "off", "reason": "OFF", "message": null }
  }
}
{
  "version": 8,
  "locale": "pt-BR",
  "capabilities": {
    "pix.send": { "state": "maintenance", "reason": "MAINTENANCE", "message": "Pix em manutenção com o banco parceiro. Saldo e extrato seguem normais." },
    "products.receivables": { "state": "off", "reason": "OFF", "message": null }
  }
}

userId e attributes vêm do aparelho e servem só para a tela refletir (percentual e segmento); a operação em si é recusada pelo portão do servidor, com atributos que ele confia. 503 quando o feature-flags não responde — o app segue com o último estado que conhece.


10

Início rápido

Do zero ao app lendo conteúdo publicado. Em ambiente local (bun run dev:cms, porta 3037).

1. Autentique-se no IAM

bash
TOKEN=$(curl -s -X POST http://localhost:3001/iam/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@test.com","password":"password123"}' | jq -r '.data.accessToken')
TOKEN=$(curl -s -X POST http://localhost:3001/iam/api/v1/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@test.com","password":"password123"}' | jq -r '.data.accessToken')

2. Crie o espaço, com os contratos do app

bash
curl -s -X POST http://localhost:3037/cms/api/v1/spaces \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "key": "banco-app",
    "name": "App do banco",
    "allowedExternalHosts": ["ajuda.banco.com.br"],
    "internalRoutes": [{"key":"extrato","params":[{"name":"periodo","type":"enum","values":["30d","90d"]}]}],
    "slots": [{"key":"home.top","allowedTypes":["ribbon","alert"]}]
  }' | jq '.key, .slots'
curl -s -X POST http://localhost:3037/cms/api/v1/spaces \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{
    "key": "banco-app",
    "name": "App do banco",
    "allowedExternalHosts": ["ajuda.banco.com.br"],
    "internalRoutes": [{"key":"extrato","params":[{"name":"periodo","type":"enum","values":["30d","90d"]}]}],
    "slots": [{"key":"home.top","allowedTypes":["ribbon","alert"]}]
  }' | jq '.key, .slots'

Esperado: "banco-app" e o slot declarado.

3. Dê a marca ao espaço

bash
curl -s -X PUT http://localhost:3037/cms/api/v1/spaces/banco-app/theme \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"tokens":{"primary":"#0b3d91","secondary":"#123f6d","accent":"#f5a623","surface":"#ffffff","surfaceAlt":"#f2f4f8","text":"#111111","textMuted":"#5b6472","success":"#127a42","warning":"#8a6100","danger":"#b3261e","info":"#0b5cab"}}' \
  | jq '.derived, .warnings'
curl -s -X PUT http://localhost:3037/cms/api/v1/spaces/banco-app/theme \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"tokens":{"primary":"#0b3d91","secondary":"#123f6d","accent":"#f5a623","surface":"#ffffff","surfaceAlt":"#f2f4f8","text":"#111111","textMuted":"#5b6472","success":"#127a42","warning":"#8a6100","danger":"#b3261e","info":"#0b5cab"}}' \
  | jq '.derived, .warnings'

Esperado: {"onPrimary":"#ffffff","onPrimaryDark":"#ffffff"} e warnings: [].

4. Crie a primeira peça

bash
curl -s -X POST http://localhost:3037/cms/api/v1/spaces/banco-app/entries \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"key":"faixa-manutencao","type":"ribbon","placement":"home.top",
       "fields":{"pt-BR":{"text":"Manutenção entre 2h e 4h."}}}' | jq '.key, .type'
curl -s -X POST http://localhost:3037/cms/api/v1/spaces/banco-app/entries \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"key":"faixa-manutencao","type":"ribbon","placement":"home.top",
       "fields":{"pt-BR":{"text":"Manutenção entre 2h e 4h."}}}' | jq '.key, .type'

5. Publique

bash
curl -s -X POST http://localhost:3037/cms/api/v1/spaces/banco-app/releases \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"note":"primeira publicação"}' \
  | jq '.version, .sha256, .keyId'
curl -s -X POST http://localhost:3037/cms/api/v1/spaces/banco-app/releases \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"note":"primeira publicação"}' \
  | jq '.version, .sha256, .keyId'

Esperado: 1, o hash e o keyId da chave criada no primeiro uso.

6. Emita a chave do app e leia como o app leria

bash
KEY=$(curl -s -X POST http://localhost:3037/cms/api/v1/spaces/banco-app/delivery-keys \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"iOS produção"}' | jq -r '.key')

curl -s -D- -o /dev/null http://localhost:3037/cms/delivery/v1/spaces/banco-app/manifest \
  -H "X-CMS-Delivery-Key: $KEY" | grep -i etag
KEY=$(curl -s -X POST http://localhost:3037/cms/api/v1/spaces/banco-app/delivery-keys \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"name":"iOS produção"}' | jq -r '.key')

curl -s -D- -o /dev/null http://localhost:3037/cms/delivery/v1/spaces/banco-app/manifest \
  -H "X-CMS-Delivery-Key: $KEY" | grep -i etag

Esperado: ETag: "<sha256>". Repita com -H 'If-None-Match: "<sha256>"' e a resposta é 304.

Atenção. Os comandos acima foram escritos a partir das rotas em src/cms/routes/*.ts e não foram executados contra um ambiente ao vivo nesta versão (o bloco ainda não foi ao ar). O caminho equivalente está coberto pelos testes unitários em tests/unit/cms/.


11

Receitas

Receita 1 — Campanha com data para entrar e para sair

Objetivo. Peça que aparece sozinha na sexta e some no domingo à meia-noite.

bash
curl -X POST .../spaces/banco-app/entries -d '{
  "key":"portabilidade-outubro","type":"promo","placement":"home.destaque","priority":50,
  "schedule":{"from":"2026-10-03T00:00:00.000Z","until":"2026-10-05T23:59:59.000Z","timezone":"America/Sao_Paulo"},
  "fields":{"pt-BR":{"title":"Traga seu salário","text":"Portabilidade em 2 minutos.",
    "cta":{"label":"Começar","action":{"kind":"internal","route":"portabilidade"}}}}}'
curl -X POST .../spaces/banco-app/entries -d '{
  "key":"portabilidade-outubro","type":"promo","placement":"home.destaque","priority":50,
  "schedule":{"from":"2026-10-03T00:00:00.000Z","until":"2026-10-05T23:59:59.000Z","timezone":"America/Sao_Paulo"},
  "fields":{"pt-BR":{"title":"Traga seu salário","text":"Portabilidade em 2 minutos.",
    "cta":{"label":"Começar","action":{"kind":"internal","route":"portabilidade"}}}}}'

Armadilha. A rota portabilidade precisa estar no allowlist do espaço; se não estiver, a criação falha com action.route_not_allowed — e falha de novo na publicação, porque o release revalida tudo contra o espaço de agora.

Receita 2 — Teste A/B com variantes

Objetivo. Duas chamadas, mesma peça, medidas.

bash
curl -X POST .../entries -d '{
  "key":"cadastro-biometria","type":"alert","placement":"home.top",
  "fields":{"pt-BR":{"title":"Ative a biometria","text":"Mais segurança no login."}},
  "variants":[
    {"key":"A","weight":50,"fields":{"pt-BR":{"title":"Ative a biometria","text":"Mais segurança no login."}}},
    {"key":"B","weight":50,"fields":{"pt-BR":{"title":"Login em 1 segundo","text":"Ative a biometria."}}}]}'
curl -X POST .../entries -d '{
  "key":"cadastro-biometria","type":"alert","placement":"home.top",
  "fields":{"pt-BR":{"title":"Ative a biometria","text":"Mais segurança no login."}},
  "variants":[
    {"key":"A","weight":50,"fields":{"pt-BR":{"title":"Ative a biometria","text":"Mais segurança no login."}}},
    {"key":"B","weight":50,"fields":{"pt-BR":{"title":"Login em 1 segundo","text":"Ative a biometria."}}}]}'

Depois: GET /spaces/banco-app/analytics?entryId=… devolve byVariant com ctr por variante.

Armadilha. Peso total zero é recusado (variant.weight). E a variante é sorteada pela semente anonymousId: sem ela, todo mundo cai na mesma.

Receita 3 — Proteger e mudar a copy de segurança

Objetivo. Marcar um aviso como protegido e, depois, alterá-lo com aprovação formal.

A marca de proteção não vive na rota do editor. Ela tem rota própria, sob CMS_SECURITY_APPROVER:

bash
# 1) O aprovador protege a chave e define a regra (apertar aplica direto)
curl -X PUT .../spaces/banco-app/strings/pt-BR/protection -d '{"entries":[
  {"key":"pix.review.newrecipientwarning","protected":true,
   "rules":{"requiredVariables":["{valor}"],"minLength":20}}]}'
# 1) O aprovador protege a chave e define a regra (apertar aplica direto)
curl -X PUT .../spaces/banco-app/strings/pt-BR/protection -d '{"entries":[
  {"key":"pix.review.newrecipientwarning","protected":true,
   "rules":{"requiredVariables":["{valor}"],"minLength":20}}]}'
bash
# 2) O editor propõe o texto novo — só chave e valor, nada de proteção
curl -X PUT .../spaces/banco-app/strings/pt-BR -d '{"entries":[
  {"key":"pix.review.newrecipientwarning",
   "value":"Confira o destinatário: {valor} será enviado e não dá para desfazer."}]}'
# 2) O editor propõe o texto novo — só chave e valor, nada de proteção
curl -X PUT .../spaces/banco-app/strings/pt-BR -d '{"entries":[
  {"key":"pix.review.newrecipientwarning",
   "value":"Confira o destinatário: {valor} será enviado e não dá para desfazer."}]}'

Resposta: applied: 0 e pendingApproval: [{ key, approvalId }] — a string publicada continua a anterior.

bash
# 3) OUTRO usuário, com CMS_SECURITY_APPROVER, aprova
curl -X POST .../spaces/banco-app/approvals/<id>/approve -d '{"reason":"revisado pelo jurídico"}'
# 3) OUTRO usuário, com CMS_SECURITY_APPROVER, aprova
curl -X POST .../spaces/banco-app/approvals/<id>/approve -d '{"reason":"revisado pelo jurídico"}'

Armadilhas. (1) Publicar com solicitação pendente não é erro: o release sai com o texto anterior e warnings lista a chave pendente. (2) Desproteger também passa por aprovação — um aprovador sozinho não tira a proteção nem afrouxa a regra; vira solicitação para um segundo. Apertar (proteger, exigir mais) aplica na hora.

Receita 4 — Voltar atrás

Objetivo. Desfazer uma publicação ruim.

bash
curl -X POST .../spaces/banco-app/releases/7/rollback -H "Authorization: Bearer $TOKEN"
curl -X POST .../spaces/banco-app/releases/7/rollback -H "Authorization: Bearer $TOKEN"

Nasce a versão 8 com o conteúdo da 7, assinada de novo. Nada é apagado.

Armadilhas. (1) Rollback não altera o rascunho: o que o editor vê continua sendo o rascunho atual — corrija-o também, senão a próxima publicação traz o problema de volta. (2) O release alvo passa pelas regras de hoje: se um host saiu do allowlist, uma rota foi removida ou um componente deixou de ser aceito no slot, a resposta é 400 com details.reason = "rollback.blocked" e a lista do que bloqueou — voltar conteúdo não reabre um controle que foi fechado. (3) Chave protegida não volta ao texto antigo: ela fica no valor corrente e a resposta traz strings.protected_kept.

Receita 5 — Pôr o Pix em manutenção (e o menu refletir)

Objetivo. Suspender o envio de Pix por duas horas, com aviso escrito, sem tirar o item da tela.

O estado mora no feature-flags; o texto, aqui. Três passos, em dois blocos:

bash
# 1) O aviso, como string do espaço (publique depois — entra no release)
curl -X PUT .../spaces/banco-app/strings/pt-BR -d '{"entries":[
  {"key":"pix.maintenance","value":"Pix em manutenção com o banco parceiro. Saldo e extrato seguem normais."}]}'

# 2) O item do menu aponta para a chave
curl -X PUT .../spaces/banco-app/navigation -d '{"items":[
  {"key":"home","placement":"tabbar","order":0,"label":{"pt-BR":"Início"},"icon":"home"},
  {"key":"pix","placement":"tabbar","order":1,"label":{"pt-BR":"Pix"},"icon":"send","capability":"pix.send"}]}'
curl -X POST .../spaces/banco-app/releases -d '{"note":"menu com pix.send"}'

# 3) No feature-flags, quem está de plantão desliga (TOGGLE basta)
curl -X PUT http://localhost:3010/feature-flags/api/v1/flags/$FLAG_ID/state \
  -d '{"state":"maintenance","maintenanceMessageKey":"pix.maintenance"}'
# 1) O aviso, como string do espaço (publique depois — entra no release)
curl -X PUT .../spaces/banco-app/strings/pt-BR -d '{"entries":[
  {"key":"pix.maintenance","value":"Pix em manutenção com o banco parceiro. Saldo e extrato seguem normais."}]}'

# 2) O item do menu aponta para a chave
curl -X PUT .../spaces/banco-app/navigation -d '{"items":[
  {"key":"home","placement":"tabbar","order":0,"label":{"pt-BR":"Início"},"icon":"home"},
  {"key":"pix","placement":"tabbar","order":1,"label":{"pt-BR":"Pix"},"icon":"send","capability":"pix.send"}]}'
curl -X POST .../spaces/banco-app/releases -d '{"note":"menu com pix.send"}'

# 3) No feature-flags, quem está de plantão desliga (TOGGLE basta)
curl -X PUT http://localhost:3010/feature-flags/api/v1/flags/$FLAG_ID/state \
  -d '{"state":"maintenance","maintenanceMessageKey":"pix.maintenance"}'

O app chama POST /delivery/v1/spaces/banco-app/capabilities e recebe pix.send em maintenance com o texto resolvido; resolveNavigation mantém o item, marcado. Religar é {"state":"on"} — e exige FEATURE_FLAGS_PUBLISH.

Armadilhas. (1) A string precisa estar publicada: o aviso é resolvido nas strings do release corrente, não do rascunho. Sem ela, cai em capability.maintenance.default; sem essa também, o app usa o texto embarcado. (2) O item visible: false continua exigindo página publicável: o menu inteiro é validado, não só o que aparece.

Receita 6 — Criar um item de menu que abre uma página

Objetivo. Um "Avisos" no menu de página, sem versão nova do app.

bash
# A página é uma entrada do espaço (tipo customizado ou do catálogo), num slot do app
curl -X POST .../spaces/banco-app/entries -d '{"key":"avisos","type":"custom:pagina","placement":"page.body",
  "fields":{"pt-BR":{"titulo":"Avisos","corpo":"…"}}}'
# → id 5f1c…

curl -X PUT .../spaces/banco-app/navigation -d '{"items":[ …,
  {"key":"page:5f1c…","placement":"menu","order":0,"label":{"pt-BR":"Avisos"},"icon":"bell"}]}'
# A página é uma entrada do espaço (tipo customizado ou do catálogo), num slot do app
curl -X POST .../spaces/banco-app/entries -d '{"key":"avisos","type":"custom:pagina","placement":"page.body",
  "fields":{"pt-BR":{"titulo":"Avisos","corpo":"…"}}}'
# → id 5f1c…

curl -X PUT .../spaces/banco-app/navigation -d '{"items":[ …,
  {"key":"page:5f1c…","placement":"menu","order":0,"label":{"pt-BR":"Avisos"},"icon":"bell"}]}'

Armadilhas. page:<id> só aceita entrada deste espaço, de tipo página (page, richText, faq — um banner é peça dentro de uma tela, não um destino) e com <id> em formato uuid; apagar a entrada faz a próxima publicação falhar com navigation.page_not_found — corrija o menu antes. E um item nunca abre rota que o app não declarou: navigation.route_not_allowed não tem contorno pelo editor.

Receita 7 — Preview no aparelho

bash
TOKEN_PREVIEW=$(curl -s -X POST .../spaces/banco-app/preview-tokens -d '{"ttlMinutes":15}' | jq -r '.token')
curl -s .../delivery/v1/spaces/banco-app/preview -H "X-CMS-Preview-Token: $TOKEN_PREVIEW" | jq '.manifest.preview'
TOKEN_PREVIEW=$(curl -s -X POST .../spaces/banco-app/preview-tokens -d '{"ttlMinutes":15}' | jq -r '.token')
curl -s .../delivery/v1/spaces/banco-app/preview -H "X-CMS-Preview-Token: $TOKEN_PREVIEW" | jq '.manifest.preview'

Armadilha. O manifesto de preview vem sem assinatura e com preview: true. O app deve usá-lo em memória e nunca gravá-lo como último manifesto válido.


12

Integração com outros building blocks

BBComo se relacionaObrigatório
iamEmite o token e as permissões CMS_*Sim
file-storageGuarda logo, ícone e mídia; o release lê os bytes para carimbar o sha256Sim, se houver asset
audit-trailConsome cms.entry.*, cms.release.* e cms.approval.* do stream iam-eventsRecomendado
api-keysChave de API da organização para automação do editor (ex.: publicar por pipeline)Opcional
feature-flagsEstado das chaves de capacidade do menu, pela fachada FeatureFlagsFacade (BB-6). A audiência por { segment } continua resolvida no cliente (§15)Sim, se o menu referenciar capacidade
webhooks-engineNotificar sistemas do cliente a cada publicaçãoNão implementado (§15)

Outro bloco ou produto que rode dentro da plataforma lê o manifesto pela fachada, sem chave de entrega e sem depender estaticamente do módulo:

typescript
import { Container } from 'typedi'
import { CmsFacadeToken } from '@shared/facades/cms.facade'

const cms = Container.get(CmsFacadeToken)
const result = await cms.getPublishedManifest(organizationId, 'banco-app')
import { Container } from 'typedi'
import { CmsFacadeToken } from '@shared/facades/cms.facade'

const cms = Container.get(CmsFacadeToken)
const result = await cms.getPublishedManifest(organizationId, 'banco-app')

13

Configuração e operação

VariávelDescriçãoObrigatóriaPadrão
CMS_SIGNING_MASTER_KEY64 hex (32 bytes); cifra a privada Ed25519 de cada organizaçãoSim, quando o módulo roda—
CMS_PUBLIC_URLURL pública do bloco; entra no endereço dos assets dentro do manifestoRecomendadavazio (endereço relativo)
CMS_PREVIEW_TOKEN_SECRETSegredo do token de preview; sem ela, é derivado da master key do blocoNãoderivado
CMS_ANALYTICS_RATE_LIMITLotes de eventos por minuto, por chave de entregaNão6000
CMS_ANALYTICS_RETENTION_DAYSRetenção padrão do evento cruNão90
MODULE_CMS_URL / MODULE_CMS_PORTDescoberta do módulo em standaloneNão`` / 3037
MODULE_FILE_STORAGE_URLNecessária em standalone para resolver assetsSim, se houver asset—
DATABASE_URL, REDIS_URL, JWT_SECRETInfra comum a todos os blocosSim—

Infra. Postgres (schema cms), Redis (limite de taxa da ingestão) e o file-storage para assets. Sem dependência de serviço externo.

Limites.

LimiteValor
Lote de eventos100 por requisição
Strings por requisição500
Variantes por entrada10
Itens de carrossel10
Profundidade do tipo customizado5 níveis
Asset lido para hash10 MB, 10 s de timeout
Token de preview1 a 60 minutos, revogável pelo jti
Janela de occurredAt48 h para trás, 5 min para a frente

Catálogo de erros

details.reasonSignificaO que fazer
placement.unknownSlot não existe no espaçoDeclare o slot em PATCH /spaces/:key
placement.type_not_allowedSlot não aceita aquele tipoAjuste allowedTypes ou o tipo da entrada
text.markup_not_allowedHá HTML/script/javascript: no textoConteúdo é texto, não marcação
action.route_not_allowedRota interna fora do allowlistO app precisa declarar a rota
action.param_not_declaredParâmetro não declarado para a rotaDeclare o parâmetro, se for seguro
action.scheme_not_httpsLink externo em httpUse https
action.host_not_allowedHost externo fora do allowlistInclua o host no espaço
navigation.route_not_allowedItem do menu aponta para rota fora de internalRoutesO app precisa declarar a rota — o menu não cria capacidade
navigation.page_not_foundpage:<id> sem entrada viva de tipo página (ou não publicável, na publicação)Corrija o id, reabilite a página ou use um tipo de NAVIGATION_PAGE_TYPES
navigation.page_invalidO sufixo de page: não é um uuidUse o id da entrada; antes disso a consulta batia na coluna uuid e virava 500
navigation.icon_unknownicon não é nome lucide em kebab-case (emoji cai aqui)Use um nome de catalog/icons.ts
navigation.tabbar_overflowMais de 5 itens visíveis na barraOculte ou mova para menu
approval.four_eyes (403)Autor tentou aprovar a si mesmoOutro usuário com CMS_SECURITY_APPROVER aprova
asset.unreadableO release não conseguiu ler o arquivoVerifique MODULE_FILE_STORAGE_URL e se o upload foi confirmado
rollback.blockedO release alvo não passa nas regras atuais do espaçodetails.blocked lista entrada e motivo; corrija o espaço ou publique do rascunho
occurred_at_out_of_windowoccurredAt fora da janela (48 h atrás, 5 min à frente)Confira o relógio do aparelho; o evento é descartado
release.noneEspaço sem releaseO app usa os defaults embarcados
rate_limited (429)Ingestão acima do limiteRespeite Retry-After

Observabilidade. Spans HTTP pelo middleware comum; eventos de domínio no stream iam-events (lidos pelo audit-trail); /cms/health e /cms/health/ready com o estado de Postgres e Redis. Nenhum log carrega conteúdo de entrada — só ids e hashes.


14

Segurança e compliance

Isolamento entre tenants. Toda leitura entra pelo espaço, e o espaço é encontrado por (organizationId do token, key) — CmsSpaceRepository.findByKey. Não existe rota que aceite organização por parâmetro. Espaço de outra organização responde 404, não 403: não se confirma a existência de recurso alheio.

Conteúdo não vira execução. Todo campo de texto é recusado se contiver tag HTML, <script>, <iframe>, <img, javascript: ou on<evento>=, e a comparação é feita sobre o texto normalizado — caractere de controle e invisível são removidos antes, porque java<TAB>script: é lido como javascript: por quem renderiza. Os schemas de componente são .strict(): campo desconhecido é erro, nunca campo ignorado em silêncio. richText aceita negrito, itálico, link e lista.

A allowlist de host vale para o texto inteiro, não só para [rótulo](url). A varredura extrai todo candidato a endereço — URL crua no meio da frase, link por referência ([x]: https://…), //host/…, www.host/… e qualquer esquema que navegue ou execute sem barras (javascript:, data:, tel:, mailto:…) — e cada um passa pela mesma regra da ação externa. Um renderizador com autolink transforma todos eles em link do mesmo jeito; validar só a sintaxe de link do markdown era um contorno de uma linha.

Ação não carrega transação. Uma ação é none, internal (rota do allowlist, parâmetro declarado) ou external (https + host do allowlist, comparação exata de hostname). Como o único vocabulário possível é o que o espaço declarou, não há como uma peça de conteúdo transportar valor, chave Pix, conta ou beneficiário. O cliente deve abrir link externo no navegador do sistema, com aviso de saída do app — nunca em WebView interna.

Integridade ponta a ponta. O manifesto é serializado de forma canônica (chaves ordenadas), hasheado em SHA-256 e assinado em Ed25519 com a chave da organização. A mensagem assinada é organizationId|spaceKey|version|sha256|signedAt: a organização entra no que se assina porque spaceKey é único por organização — duas casas podem ter banco-app, e sem esse amarrio uma assinatura válida de uma valeria na outra. A verificação também busca a chave escopada na organização. A privada fica cifrada com CMS_SIGNING_MASTER_KEY (AES-256-GCM); a pública é publicada. O cliente verifica antes de aplicar: falhou, descarta e segue com o último manifesto válido; não tem nenhum, usa os defaults embarcados.

Rollback não reabre controle fechado. Republicar uma versão antiga passa pelas mesmas guardas da publicação: as entradas são revalidadas contra o espaço de agora e, se alguma não passa, o rollback falha com a lista do que bloqueou (rollback.blocked). Pelo mesmo motivo, chave protegida não volta ao texto antigo — isso desfaria uma aprovação sem o segundo par de olhos.

Contraste (acessibilidade). O servidor mede a razão de contraste do texto sobre primary, nos modos claro e escuro. Abaixo de 4.5:1 (WCAG 2.1 AA para texto), a publicação avisa (theme.contrast_below_aa, com a razão medida e o modo) e o token derivado onPrimary entregue ao app vira a cor escura. Não bloqueia: a marca é do cliente, a medição é nossa.

Credenciais. A chave de entrega é somente leitura, escopada num espaço, devolvida em claro uma única vez e guardada como SHA-256; revogar é imediato. O token de preview vive no máximo 60 minutos, só vale naquele espaço, é assinado com segredo próprio do bloco (derivado da master key, nunca o JWT_SECRET da plataforma — ele vai para o aparelho de quem testa) e tem jti registrado: revogar mata o token antes do exp.

Menu não cria capacidade. Um item de navegação só aponta para uma rota da lista fechada do espaço ou para uma página do próprio espaço — e a validação roda na escrita, na publicação e no rollback, pela mesma função. Não há como um item abrir rota que o app não declarou, nem carregar valor ou favorecido (ele não tem params). O ícone é nome lucide validado por lista: emoji não resolve para componente e o item sairia vazio, sem erro, então é recusado antes.

Esconder não é desligar, e o menu segue sendo só a dica de tela. O cms mostra o estado da capacidade (POST …/capabilities), mas não o decide nem o impõe: os atributos que o app manda ali servem para a tela refletir público e percentual, e nada mais.

Quem impõe é o bloco dono da capacidade, e desde o BB-6b isso existe. O BaaS chama assertCapability em pix.send, ted.send, boleto.issue e boleto.write_off antes de decifrar a credencial do provedor: um app adulterado que ignore o menu esbarra na API e recebe CAPABILITY_OFF (403) ou CAPABILITY_MAINTENANCE (503) — o mesmo código de motivo que esta tela mostra.

A cobertura, porém, é a desses quatro pontos: nos blocos que ainda não chamam o portão, o estado continua sendo conteúdo de tela. Quem precisa de recusa de operação num bloco que não consome o portão usa permissão, política ou limite do bloco dono — ou passa a consumi-lo, como o baas.

Quatro olhos, dos dois lados. Chave marcada protected não muda sem aprovação de um usuário com CMS_SECURITY_APPROVER diferente de quem propôs — e tirar a proteção (ou afrouxar a regra) também é uma solicitação, decidida por um segundo aprovador. A marca de proteção não existe no corpo da rota do editor: ela tem rota própria (PUT …/strings/:locale/protection, CMS_SECURITY_APPROVER), e a proteção efetiva de uma chave é sempre a do estado atual dela, nunca a informada no pedido. Sem isso, bastava mandar protected: false junto com o texto novo para desprotegir e reescrever a copy de segurança no mesmo pedido — o controle existia e era contornável por quem ele deveria limitar. A regra é do servidor (CmsStringsService.upsert, setProtection e decide), com testes dedicados.

LGPD. O analytics é pseudônimo: o anonymousId é gerado pelo app e não se liga ao cliente bancário. anonymousId e sessionId têm formato fechado (opaco: letras, dígitos e ., -, _, :), e o bloco recusa — evento a evento, sem derrubar o lote — o que tiver campo com cara de dado pessoal (cpf, email, telefone, conta, cartão…) ou identificador que pareça CPF ou e-mail. O occurredAt vem do relógio do cliente e só é aceito dentro de uma janela (48 h para trás, 5 min para a frente): fora dela o evento é descartado, para que aparelho com data errada não escreva em série histórica já lida. A base legal típica é interesse legítimo de medição de conteúdo próprio; a retenção do evento cru é configurável (analyticsRetentionDays, padrão 90 dias) e, expirada, restam apenas agregados, que não identificam ninguém.

Permissões

PermissãoDá acesso a
CMS_READLeitura de espaços, conteúdo, tema, strings, releases e analytics
CMS_EDITORCriar e alterar espaços, tipos, entradas, tema e strings
CMS_PUBLISHERPublicar, fazer rollback, emitir/revogar chave de entrega, rotacionar chave
CMS_SECURITY_APPROVERAprovar ou rejeitar mudança em chave protegida

15

Limitações conhecidas

  • Sem tela. O bloco é API primeiro. Quem entrega a experiência editorial é o produto que integra.
  • Cobertura de teste é de unidade mais um fluxo de integração. 113 testes unitários (tests/unit/cms) e um teste de ponta a ponta com testcontainers, de 11 passos (tests/integration/cms/content-release-flow.test.ts). Não há e2e HTTP contra ambiente ao vivo — o bloco ainda não subiu.
  • Segmento do feature-flags na audiência ainda é resolvido no cliente. A audiência aceita { "segment": "..." } e o manifesto o carrega; a fachada do feature-flags existe desde o BB-6, mas serve ao estado de capacidade, não à audiência das entradas — resolver segmento de entrada no servidor exigiria mandar atributo de cliente a cada leitura do manifesto.
  • O portão de capacidade só recusa operação no baas. O menu esconde o item em off e o marca em maintenance; a recusa de verdade depende de o bloco dono chamar assertCapability, e desde o BB-6b quem faz isso é o baas, em quatro operações (§14). Nos demais blocos o estado segue sendo conteúdo de tela, não controle de acesso.
  • Só entrada de PÁGINA é destino de menu. page:<entryId> aceita page, richText e faq (NAVIGATION_PAGE_TYPES). Apontar o menu para um banner ou um ribbon é recusado com navigation.page_not_found — eles são peças dentro de uma tela, não uma tela. Um espaço que queira outro tipo de página precisa de mudança no bloco.
  • Estado de capacidade não é cacheado pelo app via ETag. POST …/capabilities é no-store: cada abertura de tela que precisa do estado faz uma chamada. O app deve guardar o último estado e usá-lo offline (§16).
  • Retenção sem agendador. A purga do evento cru existe como método do serviço (CmsAnalyticsService.purge), mas ainda não roda sozinha.
  • Upload de asset é do file-storage. O editor sobe o arquivo lá e referencia o fileId aqui.
  • Publicação falha se um asset não puder ser lido. É deliberado — manifesto sem hash de asset não entrega a garantia de integridade —, mas significa que file-storage fora do ar impede publicar.
  • Sem medição no billing e sem webhook de publicação. Os dois entram quando houver medidor e necessidade concreta.
  • Nunca foi para staging nem produção. O PR que introduz o bloco não faz deploy; a chave CMS_SIGNING_MASTER_KEY ainda não está no SOPS dos ambientes, e o serviço não entrou na frota nativa (scripts/deploy-bb-prod.sh).
  • A varredura de endereço no texto tem uma borda. Ela cobre esquema com //, os esquemas que navegam ou executam sem barras, //host e www.host. Um domínio nu no meio da frase (compre.em.example.com, sem www. e sem esquema) não é tratado como endereço — distinguir isso de prosa com ponto (arquivo.pdf, versão 2.0) geraria recusa de texto legítimo. O contrato do cliente cobre o resto: o app não cria link a partir de texto que o CMS não entregou como ação.
  • Rollback estrito custa um passo. Se o espaço apertou uma regra desde a publicação alvo, o rollback falha e exige corrigir o espaço ou publicar do rascunho. É deliberado: a alternativa era reabrir, assinado, um controle que alguém fechou de propósito.
  • Frequência é contrato, não contagem. frequencyCap (once, perSession, daily) viaja no manifesto e quem respeita é o cliente; o servidor não conta exibição por pessoa — e não vai contar, porque isso exigiria identificar quem viu.

16

Perguntas frequentes

O app precisa de internet para mostrar o conteúdo? Não. O app guarda o último manifesto válido e usa offline. Sem nenhum manifesto, cai nos defaults que vieram na build. A rede só é necessária para receber uma versão nova.

Como sei que o conteúdo que chegou no aparelho é o que eu publiquei? Cada release tem sha256 do manifesto e assinatura Ed25519 da sua organização. O app recalcula o hash e verifica a assinatura com a chave pública antes de aplicar. Adulterou no caminho, não aplica.

Posso mandar HTML, só desta vez, para aquela tela específica? Não. Nenhum campo aceita marcação, e isso é intencional: é o que separa "conteúdo" de "código de terceiro rodando dentro do app do banco". Se falta um formato, o caminho é um componente novo no catálogo ou um tipo customizado.

E se eu precisar de um componente que não existe no catálogo? Duas saídas: um tipo customizado no seu espaço, se o app souber renderizar aquela estrutura; ou um componente novo no catálogo, que sobe a catalogVersion e exige app atualizado. Componente que o app não conhece é ignorado por ele, não quebra a tela.

Um editor consegue tirar a proteção de uma chave para mexer nela? Não. A marca de proteção não existe no corpo da rota do editor — ela tem rota própria, sob CMS_SECURITY_APPROVER —, e desproteger (ou afrouxar a regra) é uma solicitação que precisa de um segundo aprovador. Proteger mais, ao contrário, aplica na hora.

Quem pode publicar? Quem tem CMS_PUBLISHER. Editar é CMS_EDITOR. Aprovar copy protegida é CMS_SECURITY_APPROVER, e nunca a mesma pessoa que propôs.

Publiquei algo errado. Como volto? POST /releases/:version/rollback. Nasce uma versão nova com o conteúdo da antiga, assinada de novo. Nada é apagado — e lembre de corrigir o rascunho também.

O analytics identifica o cliente? Não. O identificador é pseudônimo, gerado pelo app, e o bloco recusa payload com cara de dado pessoal. Serve para comparar peças e variantes, não para perfilar pessoa.

Dá para usar em um hotsite, e não só no app? Sim. O espaço é genérico: o que muda é quem consome o manifesto. A regra de ação continua valendo, e para um site você declara as rotas internas que o site expõe.

Posso criar um item de menu que abre uma tela nova? Não. Item novo aponta para uma rota que o app já declarou no espaço ou para uma página de conteúdo deste espaço. Se a tela não existe no app, o caminho é build — o menu não cria capacidade.

O que o app faz quando não consegue perguntar o estado das capacidades? Usa o último estado que recebeu; sem nenhum, trata tudo como on. Desligar é ato explícito, não ausência de dado — e o portão do servidor recusa a operação de qualquer forma.

Preciso de um espaço por ambiente? É o que recomendamos: banco-app e banco-app-homolog, cada um com sua chave de entrega. Preview resolve o teste pontual; ambiente separado resolve o teste contínuo.