CMS
AlphaCor, texto e aviso do aplicativo mudam hoje, sem passar pela loja
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.
- 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
- 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
- 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
45 endpoints em 7 recursos.
/cms/api/v1/cms/api/v1/spaces/cms/api/v1/spaces/cms/api/v1/spaces/cms/api/v1/spaces/cms/api/v1/cms/delivery/v1Resumo 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.
| Campo | Valor |
|---|---|
| id | cms |
| Categoria | Plataforma |
| Escopo | tenant (organização do token) |
| Porta (standalone) | 3037 |
| Alias | @cms |
| Status | alpha |
O problema
negócioO 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.
remoteConfigcom 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.
Proposta de valor
negócio| Antes | Depois |
|---|---|
| Texto errado espera o próximo release | Texto corrigido e publicado no mesmo dia |
| Uma build por marca de cliente | Um app, um tema por espaço, publicado por quem opera a marca |
| Conteúdo remoto em JSON livre, validado no app | Conteúdo tipado, validado no servidor, recusado na escrita |
| "Quem mudou esse aviso?" sem resposta | Evento de trilha por mudança, com ator e hash |
| Campanha medida por achismo | Impressão, clique e CTR por peça e por variante |
| Tirar um item do menu exige build | Menu editado como conteúdo, publicado e assinado com o resto |
| Pix fora do ar "some" e o cliente acha que foi bloqueado | Estado 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.
Casos de uso reais
negócioCaso 1 — A promoção sai do ar na hora em que acabou Cenário ilustrativo
Banco digital com aplicativo próprio, campanha de portabilidade de salário com prazo fechado no domingo.
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 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.
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
Plataforma white-label que entrega o mesmo app para quatro clientes.
Cada marca exigia uma variante de build — cor, logo, textos — e cada ajuste de identidade visual virava uma fila com o time de mobile.
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.
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
Instituição com copy de segurança revisada pelo jurídico na tela de confirmação de Pix.
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 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.
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
Time de produto quer saber qual chamada leva mais gente a completar o cadastro biométrico.
Testar exigia feature flag no app e um release por hipótese.
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.
Hipótese testada sem release, com a mesma pessoa vendo sempre a mesma variante.
Mercado e diferenciais
negócioPanorama. 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ério | Catalisa CMS | Contentful | Firebase Remote Config | Braze |
|---|---|---|---|---|
| Componentes nativos tipados e fechados | Sim, catálogo em código | Não (modelo livre) | Não | Parcial (cards próprios) |
| Ação restrita a allowlist do produto | Sim | Não | Não | Não |
| Release imutável assinado (Ed25519) | Sim | Não | Não | Não |
| Aprovação por quatro olhos na copy sensível | Sim, permissão própria | Fluxo de publicação por plano | Não | Não |
| Analytics por peça, pseudônimo | Sim | Não | Parcial (via Analytics) | Sim, com perfil identificado |
| Multi-tenant nativo com isolamento por organização | Sim | Por espaço/conta | Por projeto | Por workspace |
| Editor visual maduro | Não (API primeiro) | Sim | Sim | Sim |
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.
Modelo de cobrança e ROI
negócioPrecificaçã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:
| Driver | Por que importa |
|---|---|
| Espaços ativos | Cada app/site é um conjunto próprio de tema, strings e entradas |
| Releases publicados | Publicação resolve, hasheia, assina e guarda o manifesto inteiro |
| Leituras de manifesto | Tráfego do app; mitigado por ETag/304 |
| Eventos de analytics | Ingestão, dedupe e agregação diária |
| Assets servidos | Bytes 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.
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| APPPor 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.
Conceitos e modelo de dados
Glossário
| Termo | Definiçã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 interna | Destino que o app sabe abrir, declarado com os parâmetros seguros. |
Entrada (entry) | Uma instância de componente (ou de tipo customizado) num slot. |
| Variante | Versão alternativa da entrada, com peso, para A/B. |
| Tipo customizado | Estrutura de conteúdo do produto, descrita por schema restrito. |
| String protegida | Chave de copy sensível cuja mudança exige aprovação de outro usuário. |
| Release | Fotografia imutável e assinada do rascunho inteiro. |
| Manifesto | O documento resolvido que o app aplica. |
| Chave de entrega | Credencial somente-leitura do app, escopada num espaço. |
| Item de navegação | Item da barra (tabbar, até 5 visíveis) ou do menu (menu): aponta para rota do allowlist ou para page:<entryId>. |
| Capacidade | Chave 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)
| Modelo | Tabela | Propósito | Campos-chave |
|---|---|---|---|
CmsSpace | cms_spaces | O espaço e seus contratos | key, locales, allowedExternalHosts, internalRoutes, slots |
CmsContentType | cms_content_types | Tipo customizado do produto | key, schema, version |
CmsEntry | cms_entries | Conteúdo em um slot | type, placement, fields, audience, schedule, variants |
CmsTheme | cms_themes | Marca do espaço | tokens, dark, fonts, radius, logoAssetId |
CmsString | cms_strings | Texto por chave e locale | key, locale, value, protected, rules |
CmsStringApproval | cms_string_approvals | Quatro olhos da copy protegida | proposedValue, status, requestedBy, decidedBy |
CmsRelease | cms_releases | Release imutável | version, manifest, sha256, signature, keyId |
CmsSigningKey | cms_signing_keys | Chave Ed25519 da organização | keyId, publicKeyPem, privateKeyCipher |
CmsDeliveryKey | cms_delivery_keys | Credencial do app | prefix, keyHash, revokedAt |
CmsEvent | cms_events | Evento cru pseudônimo | clientEventId, type, entryId, anonymousId |
CmsEventDaily | cms_event_daily | Agregado diário | day, entryId, variant, placement, type, count |
CmsNavigationItem | cms_navigation_items | Menu do app, por espaço | key, placement, order, visible, label, icon, capability |
CmsLegalDocument | cms_legal_documents | Documento legal do app (termos, privacidade, biometria) — o rascunho, um por chave | key, 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 --> [*]
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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
| GET | /cms/api/v1/catalog | Catálogo fechado de componentes e sua versão | CMS_READ |
| GET | /cms/api/v1/spaces | Lista os espaços da organização | CMS_READ |
| POST | /cms/api/v1/spaces | Cria um espaço | CMS_EDITOR |
| GET | /cms/api/v1/spaces/:key | Um espaço | CMS_READ |
| PATCH | /cms/api/v1/spaces/:key | Atualiza nome, locales, allowlists e slots | CMS_EDITOR |
POST /cms/api/v1/spaces
{
"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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
| GET | /cms/api/v1/spaces/:key/content-types | Tipos customizados do espaço | CMS_READ |
| POST | /cms/api/v1/spaces/:key/content-types | Cria ou versiona um tipo | CMS_EDITOR |
| GET | /cms/api/v1/spaces/:key/entries | Lista entradas (?placement=, ?type=) | CMS_READ |
| POST | /cms/api/v1/spaces/:key/entries | Cria entrada | CMS_EDITOR |
| GET | /cms/api/v1/spaces/:key/entries/:id | Uma entrada | CMS_READ |
| PATCH | /cms/api/v1/spaces/:key/entries/:id | Altera entrada | CMS_EDITOR |
| DELETE | /cms/api/v1/spaces/:key/entries/:id | Remove (soft delete) | CMS_EDITOR |
POST /cms/api/v1/spaces/:key/entries
{
"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.
Navegação (BB-6)
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
| GET | /cms/api/v1/spaces/:key/navigation | Itens do menu, ordenados | CMS_READ |
| PUT | /cms/api/v1/spaces/:key/navigation | Substitui o menu inteiro (até 40 itens) | CMS_EDITOR |
PUT /cms/api/v1/spaces/:key/navigation
{
"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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
| GET | /cms/api/v1/spaces/:key/legal-documents | Rascunhos, cada um com published (a versão que o app tem) | CMS_READ |
| PUT | /cms/api/v1/spaces/:key/legal-documents/:docKey | Grava o rascunho de um documento | CMS_EDITOR |
| DELETE | /cms/api/v1/spaces/:key/legal-documents/:docKey | Tira do rascunho (sai do app no próximo release) | CMS_EDITOR |
PUT /cms/api/v1/spaces/:key/legal-documents/doc_conta_2026_09
{ "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étodo | Rota | Descrição | Permissão |
|---|---|---|---|
| GET | /cms/api/v1/spaces/:key/theme | Tema, tokens derivados e avisos de contraste | CMS_READ |
| PUT | /cms/api/v1/spaces/:key/theme | Grava o tema | CMS_EDITOR |
| GET | /cms/api/v1/spaces/:key/strings | Strings (?locale=) | CMS_READ |
| PUT | /cms/api/v1/spaces/:key/strings/:locale | Grava em lote (até 500) — só chave e valor | CMS_EDITOR |
| PUT | /cms/api/v1/spaces/:key/strings/:locale/protection | Liga/desliga a proteção e define a regra | CMS_SECURITY_APPROVER |
| GET | /cms/api/v1/spaces/:key/approvals | Solicitações pendentes | CMS_READ |
| POST | /cms/api/v1/spaces/:key/approvals/:id/approve | Aprova (autor ≠ aprovador) | CMS_SECURITY_APPROVER |
| POST | /cms/api/v1/spaces/:key/approvals/:id/reject | Rejeita com motivo | CMS_SECURITY_APPROVER |
A resposta de PUT /theme traz derived (onPrimary, onPrimaryDark) e warnings[] — ver §14.
Releases, preview e chaves
| Método | Rota | Descrição | Permissão |
|---|---|---|---|
| GET | /cms/api/v1/spaces/:key/releases | Histórico | CMS_READ |
| POST | /cms/api/v1/spaces/:key/releases | Publica | CMS_PUBLISHER |
| GET | /cms/api/v1/spaces/:key/releases/current | Release corrente com manifesto | CMS_READ |
| GET | /cms/api/v1/spaces/:key/releases/:version | Um release | CMS_READ |
| POST | /cms/api/v1/spaces/:key/releases/:version/rollback | Republica como versão nova | CMS_PUBLISHER |
| POST | /cms/api/v1/spaces/:key/preview-tokens | Token de preview (1–60 min), devolve jti | CMS_EDITOR |
| DELETE | /cms/api/v1/spaces/:key/preview-tokens/:jti | Revoga o token antes do exp | CMS_EDITOR |
| GET | /cms/api/v1/spaces/:key/delivery-keys | Chaves do app (sem o segredo) | CMS_READ |
| POST | /cms/api/v1/spaces/:key/delivery-keys | Emite chave (segredo sai uma vez) | CMS_PUBLISHER |
| DELETE | /cms/api/v1/spaces/:key/delivery-keys/:id | Revoga | CMS_PUBLISHER |
| GET | /cms/api/v1/signing-keys | Chaves públicas da organização | CMS_READ |
| POST | /cms/api/v1/signing-keys/rotate | Rotaciona a chave de assinatura | CMS_PUBLISHER |
| GET | /cms/api/v1/spaces/:key/analytics | Contagens e CTR (?entryId=&from=&to=) | CMS_READ |
Entrega (o aplicativo)
Autenticação: X-CMS-Delivery-Key: cmsd_... (ou Authorization: Bearer cmsd_...).
| Método | Rota | Descrição |
|---|---|---|
| GET | /cms/delivery/v1/spaces/:key/manifest | Manifesto assinado; ETag forte e 304 |
| POST | /cms/delivery/v1/spaces/:key/capabilities | Estado das chaves de manifest.capabilities, com o aviso resolvido; no-store |
| GET | /cms/delivery/v1/keys | Chaves públicas da organização da chave apresentada |
| GET | /cms/delivery/v1/spaces/:key/keys | O mesmo, com o espaço no caminho |
| GET | /cms/delivery/v1/spaces/:key/assets/:fileId | Redireciona (302) para a URL fresca do asset |
| GET | /cms/delivery/v1/spaces/:key/preview | Rascunho, com X-CMS-Preview-Token |
| POST | /cms/delivery/v1/spaces/:key/events | Lote de até 100 eventos (202) |
GET manifest — resposta
{
"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
{ "userId": "device-7f3a", "locale": "pt-BR", "attributes": { "segmento": "PJ" } }{ "userId": "device-7f3a", "locale": "pt-BR", "attributes": { "segmento": "PJ" } }{
"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.
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
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
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
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
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
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
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 etagKEY=$(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 etagEsperado: 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/.
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.
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.
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:
# 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}}]}'# 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.
# 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.
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:
# 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.
# 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
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.
Integração com outros building blocks
| BB | Como se relaciona | Obrigatório |
|---|---|---|
iam | Emite o token e as permissões CMS_* | Sim |
file-storage | Guarda logo, ícone e mídia; o release lê os bytes para carimbar o sha256 | Sim, se houver asset |
audit-trail | Consome cms.entry.*, cms.release.* e cms.approval.* do stream iam-events | Recomendado |
api-keys | Chave de API da organização para automação do editor (ex.: publicar por pipeline) | Opcional |
feature-flags | Estado 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-engine | Notificar sistemas do cliente a cada publicação | Nã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:
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')Configuração e operação
| Variável | Descrição | Obrigatória | Padrão |
|---|---|---|---|
CMS_SIGNING_MASTER_KEY | 64 hex (32 bytes); cifra a privada Ed25519 de cada organização | Sim, quando o módulo roda | — |
CMS_PUBLIC_URL | URL pública do bloco; entra no endereço dos assets dentro do manifesto | Recomendada | vazio (endereço relativo) |
CMS_PREVIEW_TOKEN_SECRET | Segredo do token de preview; sem ela, é derivado da master key do bloco | Não | derivado |
CMS_ANALYTICS_RATE_LIMIT | Lotes de eventos por minuto, por chave de entrega | Não | 6000 |
CMS_ANALYTICS_RETENTION_DAYS | Retenção padrão do evento cru | Não | 90 |
MODULE_CMS_URL / MODULE_CMS_PORT | Descoberta do módulo em standalone | Não | `` / 3037 |
MODULE_FILE_STORAGE_URL | Necessária em standalone para resolver assets | Sim, se houver asset | — |
DATABASE_URL, REDIS_URL, JWT_SECRET | Infra comum a todos os blocos | Sim | — |
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.
| Limite | Valor |
|---|---|
| Lote de eventos | 100 por requisição |
| Strings por requisição | 500 |
| Variantes por entrada | 10 |
| Itens de carrossel | 10 |
| Profundidade do tipo customizado | 5 níveis |
| Asset lido para hash | 10 MB, 10 s de timeout |
| Token de preview | 1 a 60 minutos, revogável pelo jti |
Janela de occurredAt | 48 h para trás, 5 min para a frente |
Catálogo de erros
details.reason | Significa | O que fazer |
|---|---|---|
placement.unknown | Slot não existe no espaço | Declare o slot em PATCH /spaces/:key |
placement.type_not_allowed | Slot não aceita aquele tipo | Ajuste allowedTypes ou o tipo da entrada |
text.markup_not_allowed | Há HTML/script/javascript: no texto | Conteúdo é texto, não marcação |
action.route_not_allowed | Rota interna fora do allowlist | O app precisa declarar a rota |
action.param_not_declared | Parâmetro não declarado para a rota | Declare o parâmetro, se for seguro |
action.scheme_not_https | Link externo em http | Use https |
action.host_not_allowed | Host externo fora do allowlist | Inclua o host no espaço |
navigation.route_not_allowed | Item do menu aponta para rota fora de internalRoutes | O app precisa declarar a rota — o menu não cria capacidade |
navigation.page_not_found | page:<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_invalid | O sufixo de page: não é um uuid | Use o id da entrada; antes disso a consulta batia na coluna uuid e virava 500 |
navigation.icon_unknown | icon não é nome lucide em kebab-case (emoji cai aqui) | Use um nome de catalog/icons.ts |
navigation.tabbar_overflow | Mais de 5 itens visíveis na barra | Oculte ou mova para menu |
approval.four_eyes (403) | Autor tentou aprovar a si mesmo | Outro usuário com CMS_SECURITY_APPROVER aprova |
asset.unreadable | O release não conseguiu ler o arquivo | Verifique MODULE_FILE_STORAGE_URL e se o upload foi confirmado |
rollback.blocked | O release alvo não passa nas regras atuais do espaço | details.blocked lista entrada e motivo; corrija o espaço ou publique do rascunho |
occurred_at_out_of_window | occurredAt fora da janela (48 h atrás, 5 min à frente) | Confira o relógio do aparelho; o evento é descartado |
release.none | Espaço sem release | O app usa os defaults embarcados |
rate_limited (429) | Ingestão acima do limite | Respeite 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.
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
assertCapabilityempix.send,ted.send,boleto.issueeboleto.write_offantes de decifrar a credencial do provedor: um app adulterado que ignore o menu esbarra na API e recebeCAPABILITY_OFF(403) ouCAPABILITY_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ão | Dá acesso a |
|---|---|
CMS_READ | Leitura de espaços, conteúdo, tema, strings, releases e analytics |
CMS_EDITOR | Criar e alterar espaços, tipos, entradas, tema e strings |
CMS_PUBLISHER | Publicar, fazer rollback, emitir/revogar chave de entrega, rotacionar chave |
CMS_SECURITY_APPROVER | Aprovar ou rejeitar mudança em chave protegida |
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-flagsna 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 emoffe o marca emmaintenance; a recusa de verdade depende de o bloco dono chamarassertCapability, e desde o BB-6b quem faz isso é obaas, 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>aceitapage,richTextefaq(NAVIGATION_PAGE_TYPES). Apontar o menu para umbannerou umribboné recusado comnavigation.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 ofileIdaqui. - 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-storagefora do ar impede publicar. - Sem medição no
billinge 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_KEYainda 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,//hostewww.host. Um domínio nu no meio da frase (compre.em.example.com, semwww.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.
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.