Licenciamento de Módulos
Os módulos comerciais (Núcleos, Voluntários, etc.) são embarcados no bundle do Worker e ativados por uma licença — um JWT assinado com Ed25519 contendo os módulos que o cliente contratou. A verificação é 100% offline, no boot do Worker: sem chamadas de rede, sem serviço externo, sem phone-home.
Este guia cobre o ciclo de vida completo da licença, do ponto de vista do operador:
- Modelo — o que é uma licença e como ela é aplicada
- Gerar o par de chaves — uma única vez
- Emitir uma licença —
pnpm sign-license - Implantar —
LICENSEno Worker - Rotação de chave — trocar a chave de assinatura
Como funciona
Máquina de assinatura (privada):
license-private.pem ──pnpm sign-license──> LICENSE JWT ──envio──> cliente
Worker (boot):
env.LICENSE ──verifica──> chave pública embutida ──ok──> rotas do módulo registradas
└──falha──> módulo desativado (rotas 404)Formato da licença
A licença é um JWT compacto (alg: "EdDSA") com os seguintes claims:
| Claim | Descrição | Obrigatório |
|---|---|---|
sub | Identificador do cliente (ex.: igreja-batista) | sim |
modules | Lista de módulos contratados (ex.: ["nucleus"]) | sim |
plan | Nome do plano (opcional, ex.: growth) | não |
exp | Expiração em epoch seconds — ausente = sem expiração | não |
Aplicação (enforcement)
- A licença é lida de
env.LICENSEe verificada no boot porloadLicense()(assinatura Ed25519 + shape dos claims). - Uma rota de módulo só é registrada se o módulo estiver licenciado E habilitado (AND-gate):
- licenciado:
isModuleLicensed(id)— assinatura válida,idemmodules, e dentro do período de graça (ver abaixo); - habilitado:
ENABLED_MODULES(lista separada por vírgula ou*; default*).
- licenciado:
- Fail-closed: sem
LICENSE(ou com licença inválida/expirada), as rotas do módulo simplesmente não existem — retornam404. O core (presença, alunos, turmas, relatórios) nunca é afetado.
Período de graça
Após exp, o módulo continua ativo por 30 dias — o cliente em renovação não sofre corte abrupto. Depois do período de graça, as rotas do módulo são desativadas. Uma licença sem exp (emitida com --no-expiry) nunca expira.
Gerar o par de chaves
Gere o par uma única vez, na máquina de assinatura (nunca no CI, nunca no repo):
openssl genpkey -algorithm Ed25519 -out license-private.pem
openssl pkey -in license-private.pem -pubout -out license-public.pemlicense-private.pem— NUNCA commitar, nunca enviar. Guarde em um cofre de segredos / gerenciador de senhas. Quem tem essa chave consegue emitir licenças.license-public.pem— a chave pública é embutida no build do Worker, emworkers/src/license/key.ts(constanteLICENSE_PUBLIC_KEY, única e trocável — é o ponto de rotação). Não é uma env var: não pode ser trocada em runtime.
O repo mantém uma chave de dev em
workers/.dev-license-private.pem(gitignored) usada pelos testes de integração e por licenças de desenvolvimento. Antes do primeiro cliente pagante, gere um par de produção (comandos acima) e substitua a constante emkey.ts.
Emitir uma licença
Use o CLI pnpm sign-license (raiz do repo):
# via env var (PEM) ou --key-file
LICENSE_PRIVATE_KEY="$(cat license-private.pem)" pnpm sign-license \
"igreja-batista" "nucleus,volunteers" --plan growth --expiry-days 365
# ou, para uma licença que nunca expira:
LICENSE_PRIVATE_KEY="$(cat license-private.pem)" pnpm sign-license \
--no-expiry "igreja-batista" "nucleus"Opções
| Opção | Descrição | Default |
|---|---|---|
customer-id | Identificador do cliente (sub) | — |
modules | Lista separada por vírgula (ex.: nucleus,volunteers) | — |
--expiry-days N | Duração em dias | 365 |
--no-expiry | Omite exp — licença sem expiração | off |
--plan NAME | Nome do plano (plan) | — |
--key-file PATH | Caminho para a chave privada PEM (PKCS#8) | — |
--help | Mostra a ajuda | — |
A chave privada é lida de --key-file ou da env var LICENSE_PRIVATE_KEY (PEM). Sem nenhuma das duas, o CLI falha com exit 1.
Saída
- O JWT vai para o stdout (use para copiar/colar).
- Um resumo legível vai para o stderr:
license minted: customer=igreja-batista modules=[nucleus, volunteers] expiry=2027-07-31T00:00:00.000Z plan=growth
Envie o JWT por e-mail ao cliente (ou configure você mesmo na implantação — ver abaixo).
Implantar
Defina a env var LICENSE do Worker com o JWT emitido:
workers/wrangler.toml→[vars](a variável já está documentada, comentada, apontando para este guia):toml[vars] LICENSE = "eyJhbGciOiJFZERTQSJ9..."Ou no Cloudflare dashboard → Workers →
api-neemias→ Settings → Variables → adicionarLICENSE(prefiraEncryptpara a variável).(Opcional) restrinja quais módulos o deploy habilita:
tomlENABLED_MODULES = "nucleus" # default "*" — tudo que a licença permiteDeploy:
bashpnpm deploy:worker
Verificar
# Rota de módulo ativa → 401 (existe, exige autenticação)
curl -i https://api.neemias.app/api/v1/nuclei
# Módulo desativado / sem licença → 404
curl -i https://api.neemias.app/api/v1/nuclei # (com LICENSE ausente/inválida)Se a rota retorna 401, o módulo está licenciado e registrado. Se retorna 404, a licença está ausente, inválida, expirada (além da graça) ou o módulo não está em ENABLED_MODULES. Observação: uma rota registrada sem exigência de autenticação retornaria 200 — o ponto é que as rotas de um módulo não licenciado retornam 404, nunca ficam acessíveis.
Rotação de chave
Trocar a chave de assinatura invalida todas as licenças existentes — planeje a janela e re-emita tudo:
- Gere um novo par:
openssl genpkey -algorithm Ed25519 ...(comandos em Gerar o par de chaves). - Substitua a constante
LICENSE_PUBLIC_KEYemworkers/src/license/key.tspela nova chave pública. - Atualize os tokens de teste (assinados com a chave antiga deixam de validar):
workers/vitest.integration.config.ts(bindingLICENSE);workers/src/__tests__/gating-fetch.test.ts(LICENSE_NUCLEUSeLICENSE_VOLUNTEERS_ONLY). Re-emita com:pnpm sign-license --no-expiry integration-tests nucleus,volunteersepnpm sign-license --no-expiry test-volunteers-only volunteers.
- Deploy do Worker (a nova chave pública passa a valer).
- Re-emita a licença de cada cliente com a nova chave privada e envie.
Troubleshooting
| Sintoma | Causa provável | Ação |
|---|---|---|
Rotas do módulo retornam 404 | LICENSE ausente / inválida / fora da graça | Confira a env var e re-emita a licença |
401 em rota de módulo | Licença OK — rota registrada, exige autenticação | Nada a fazer (comportamento esperado) |
ENABLED_MODULES restringe mas a licença cobre mais módulos | Configuração intencional de deploy | Ajuste ENABLED_MODULES |
| Licença "válida" mas rota 404 | Módulo não está em modules da licença | Re-emita incluindo o módulo |
Referências
- Issue #466 — especificação do licenciamento (Ed25519 JWT, offline)
workers/src/license/validate.ts—loadLicense()/isModuleLicensed()workers/src/license/key.ts— chave pública embutida + rotação- Ativação de Módulos — visão do cliente