@leve-framework/backend
v1.0.5
Published
Backend do framework Leve: Node + TypeScript + PostgreSQL, sem dependências de terceiros.
Maintainers
Readme
Leve Server
Camada de servidor da stack Leve: Node + TypeScript + PostgreSQL, sem nenhuma
dependência de terceiros (só node:* built-ins) — mesmo espírito autoral
do frontend (leve/). Fala o protocolo de fio do PostgreSQL diretamente
(TCP/TLS, autenticação SCRAM-SHA-256 e queries sempre
parametrizadas) e expõe um Router HTTP próprio sobre node:http.
O suporte operacional é para o produto completo Frontend + Backend + Infra. Os módulos permanecem públicos para composição interna e testes, mas não há matriz de compatibilidade com frontends, servidores ou pipelines de outra stack. Consulte a matriz de capacidades do produto para separar o caminho integrado, recursos opt-in e contratos ainda experimentais.
Execução suportada
Na raiz do projeto, a stack local completa sobe com:
cp backend/.env.example backend/.env
docker compose up --buildIsso inicia frontend, API, worker/scheduler, PostgreSQL e Redis. Em produção,
o overlay cloud/vm/compose.prod.yaml acrescenta Caddy e deixa somente 80/443
publicamente acessíveis.
Desenvolvendo o próprio backend
O módulo tem package.json próprio para build e testes do monorepo — isso é
uma fronteira de engenharia, não um modo de produto independente. Ele mantém
zero dependências de runtime e usa apenas typescript/vitest/@types/node
no desenvolvimento.
cd backend
npm install
cp .env.example .env # ajuste pro seu Postgres local
npm run build # compila . -> dist/
npm start # roda dist/server.js (carrega .env automaticamente)npm start/npm run dev usam node --env-file-if-exists=.env
(suporte nativo do Node, sem dotenv) — se .env não existir, as
variáveis vêm de onde já estiverem no ambiente (útil em produção, onde
normalmente são injetadas pela plataforma de deploy em vez de um
arquivo). Ver .env.example pra lista completa. loadLeveConfig() consolida
banco, Redis, auth, CORS, WebAuthn, métricas, SMTP, storage e backup, com
requisitos por ambiente e erros acionáveis. O contrato, perfis e rotação de
segredos estão em CONFIGURATION.md. .env nunca é
commitado (está no .gitignore) e leve doctor usa o mesmo validador do boot.
Pra desenvolvimento com reload automático: npm run dev (equivalente
ao start, com --watch).
Criptografia de campo
Dados regulados que precisam resistir à leitura de um snapshot podem usar a
API pública createFieldEncryption(). Ela produz envelopes AES-256-GCM
versionados com key id, autentica propósito/campo/registro/tenant como AAD e
aceita chave atual + anteriores para rotação gradual. A escrita usa somente a
atual; decryptWithRotation() entrega o novo ciphertext para persistência por
compare-and-swap. O recurso é opt-in porque classificação de dados e custódia
das chaves pertencem à aplicação. Veja o
guia bilíngue de criptografia de campo.
Lifecycle da aplicação
O entrypoint oficial é defineLeveApp(). Importar o arquivo nunca abre porta:
| Método | Uso |
|---|---|
| boot() / build() | registra módulos e cria o servidor sem escutar; útil para inspeção |
| listen() | abre somente HTTP; não inicia workers/consumidores |
| ready() / start() | abre HTTP e inicia o lifecycle completo de produção |
| test() | inicia tudo em 127.0.0.1, porta livre e sem handlers de signal |
| shutdown() / stop() | recusa conexões, drena HTTP e fecha módulos/recursos em ordem inversa |
Módulos novos implementam boot, ready e shutdown. Os nomes antigos
setup, start e stop continuam aceitos para compatibilidade, mas não podem
ser misturados com o hook novo equivalente no mesmo módulo. Integrações que
mantêm conexões persistentes, como WebSocket, usam attach e detach.
app.lifecycle.phase expõe a fase atual e app.isReady só fica verdadeiro
quando todos os hooks ready terminaram. Por isso /api/ready responde 503
durante o startup e assim que o shutdown começa; /api/live continua medindo
somente se o processo está vivo.
Um teste de integração não precisa escolher porta nem iniciar outro processo:
const running = await app.test();
const response = await running.fetch('/api/ready');
expect(response.status).toBe(200);
await running.stop();test() ignora a porta configurada para produção. Para evitar que um teste
atinja acidentalmente um servidor já aberto, ele precisa ser chamado antes de
listen() ou start().
Exemplo mínimo: CRUD completo em poucas linhas
import { Pool, Router, createLeveServer, defineResource } from './index.js';
const pool = new Pool({
host: 'localhost', port: 5432,
user: 'postgres', password: 'postgres', database: 'meuapp',
});
const router = new Router();
const users = defineResource({
pool,
table: 'users',
columns: {
id: { type: 'number', readOnly: true },
name: { type: 'string', required: true, maxLength: 120 },
email: { type: 'string', required: true, maxLength: 200 },
},
filterable: ['email'],
sortable: ['name'],
});
users.mount(router, '/users');
createLeveServer({ router }).listen(3001);Isso já dá, em /users:
GET /users?page=1&pageSize=20&sort=name— lista paginadaGET /users?pagination=pages&page=1&pageSize=20— paginação de páginasGET /users?pagination=infinite&page=2&pageSize=20— lote seguinte para scroll infinitoGET /users/:id— um registroPOST /users— cria (valida campos obrigatórios, ignora campos desconhecidos/readOnly)PATCH /users/:id— atualiza parcialmenteDELETE /users/:id— remove
Sempre com queries parametrizadas (nunca concatenação de string em SQL) e
sem que o cliente HTTP consiga setar id ou qualquer coluna marcada
readOnly (proteção contra mass assignment).
Cache HTTP é opt-in e estruturado. Uma política única vale para lista e
detalhe; { list, detail } permite TTLs diferentes. private é o padrão e
acrescenta Vary: Authorization, Cookie; false envia no-store. Cache
public é recusado quando scope ou policy fazem a resposta variar por
identidade. Consulte o
guia de Cache-Control.
Consultas do modelo também podem usar cache opt-in com queryCache. find,
findOne, findBy e count ganham chave canônica, separação explícita por
tenant/usuário e invalidação automática após escrita/commit. Relações continuam
carregadas ao vivo e transações ignoram o cache. Consulte o
guia de cache de query.
O servidor standalone também negocia Brotli/gzip automaticamente para JSON,
texto, JavaScript, XML e SVG a partir de 1 KiB. O limiar e os níveis são
configuráveis em compression; compression: false desliga o recurso.
Streaming/SSE, ranges, binários e respostas que já possuem Content-Encoding
não são recomprimidos; Cache-Control: no-transform desliga por resposta.
Consulte o
guia de compressão HTTP.
Quando pagination é pages ou infinite, a resposta também inclui
pagination, page, pageSize, hasMore e total. O backend aplica o
pageSize validado diretamente no LIMIT, permitindo que o Table solicite
somente o lote necessário.
Paginação por cursor (keyset)
OFFSET 50000 faz o banco produzir cinquenta mil linhas e jogar fora 49.980 —
o custo cresce com a profundidade da página. Keyset troca isso por
WHERE (ordem, chave) > (última ordem, última chave), que com índice é busca
direta: a página mil custa o mesmo que a primeira.
E corrige um bug que o offset tem e quase ninguém associa a ele: com escrita acontecendo entre uma página e a seguinte, o offset pula e repete registros, porque a posição é uma contagem. O cursor aponta para um registro.
GET /users?pagination=cursor&pageSize=20
-> { data, pageSize, pagination: "cursor", hasMore, nextCursor }
GET /users?pagination=cursor&pageSize=20&cursor=<nextCursor>Três detalhes que não são óbvios:
- O desempate pela chave primária entra sempre na ordenação quando a coluna pedida não é única — inclusive no modo offset. Sem ele, a ordem entre valores iguais é indefinida e a fronteira entre páginas fica ambígua: registros somem ou repetem mesmo com o banco parado.
- Não há
total. Contar exigiria varrer a coleção, desfazendo o ganho. OhasMoresai de pedir uma linha a mais que opageSize. - O cursor é validado contra a ordenação. Um cursor emitido sob
sort=titleaplicado asort=-created_atresponde 400 em vez de devolver uma janela sem sentido que parece plausível.
Stores sem suporte a keyset (Redis, MongoDB, Elasticsearch) lançam erro em vez de cair no offset em silêncio — o cliente pediu uma garantia de consistência que o offset não dá.
Esses limites também são executáveis, não apenas texto. Os stores declaram
capabilities, e dataStoreStatus(store) devolve limited com as evidências
ausentes (cursorPagination, optimisticConcurrency e
operationalObservability). Um store próprio continua precisando somente das
cinco operações; capacidade não declarada conta como não comprovada. Quando o
diagnóstico opt-in é montado, passe dataStores: { nome: store } para expor
essa matriz na rota autorizada sem revelar URL ou credenciais.
Rollback de migrations
npm run migrate # aplica as pendentes
npm run migrate:down # desfaz a última
npm run migrate:down -- --steps=3Uma migration declara down junto com os statements:
export const initialSchema: Migration = {
name: '001-initial-schema',
statements: (d) => [ /* ... */ ],
// Ordem inversa da criação: 'posts' referencia 'users', então cai primeiro.
down: () => ['DROP TABLE posts', 'DROP TABLE users'],
};Duas decisões deliberadas:
- Migration sem
downaborta o rollback inteiro, antes de mexer em qualquer coisa. Descobrir no meio que a terceira é irreversível já teria desfeito as duas primeiras, deixando o schema num estado que não corresponde a nenhuma versão do código. - Rollback de schema não é rollback de dados. Um
DROP COLUMNdesfaz a estrutura e leva junto o que estava nela. O CLI avisa antes de rodar.
Modelos: uma declaração, cinco saídas
defineResource continua sendo o caminho mais curto para expor uma tabela.
defineModel vai um passo além: a mesma declaração de colunas passa a
alimentar tipos TypeScript, DDL de migration, queries tipadas, o recurso REST e
o contrato OpenAPI — em vez de essa informação ser reescrita em quatro lugares
que divergem com o tempo.
// shared/artigo.ts — seguro para servidor e navegador
import { defineModel, column, type Infer } from '@leve-framework/backend/shared';
export const artigoDefinition = defineModel({
table: 'artigos',
columns: {
id: column.serial(),
titulo: column.varchar(200).required(),
criado_em: column.timestamp().required().withDefaultNow().asReadOnly(),
},
resource: {
endpoint: '/api/artigos',
channel: 'artigos',
filterable: ['titulo'],
sortable: ['titulo', 'criado_em'],
},
});
export type Artigo = Infer<typeof artigoDefinition>;// backend/models/artigo.ts — ligação server-only
import { bindModel } from '@leve-framework/backend';
import { artigoDefinition } from '../../shared/artigo.js';
import { driver } from '../database.js';
export const artigos = bindModel(artigoDefinition, { driver });Existing defineModel({ driver, table, columns }) calls remain supported. New
applications should keep the declaration in a shared module and bind it on the
server so the frontend can consume the contract without bundling Node code.
E então:
await artigos.find({ autor_id: 3 }, { orderBy: '-criado_em', limit: 10 });
await artigos.findOne(7, { include: ['autor'] });
await artigos.count({ autor_id: 3 });
await artigos.create({ titulo: 'Olá', autor_id: 3 });
artigos.query().whereOp('titulo', 'like', 'Le%').whereIn('id', [1, 2]).all();
artigos.query().where({ autor_id: 3 }).toSql(); // o SQL exato, para inspecionar
artigos.migration('002-artigos'); // DDL nos cinco dialetos
artigos.resource({ includable: ['autor'] }).mount(router, '/api/artigos');
buildOpenApiDocument({ title: 'API', version: '1.0.0', models: [{ model: artigos, basePath: '/api/artigos' }] });No projeto gerado, leve make resource registra { model, basePath } no
manifesto e o preset publica /_leve/openapi.json somente quando
LEVE_METRICS_ENABLED=true. OpenAPI, diagnóstico e métricas compartilham o
mesmo Bearer token rotacionável; sem ele, as rotas respondem 404.
Isto não é um ORM — e a diferença importa
Não há identity map, change tracking, unit of work nem lazy loading. A ausência do lazy loading é deliberada: é ele que transforma um laço inocente em N+1, porque a query se disfarça de acesso a propriedade.
Aqui relação se pede explicitamente e é resolvida em lote, com uma query
IN:
const lista = await artigos.find({}, { include: ['autor'] });
// 2 queries para 25 artigos: uma dos artigos, uma dos autores.
// Com lazy loading seriam 26 — e ninguém veria.No REST, ?include=autor funciona igual, mas exige allowlist (includable):
sem ela, o cliente escolheria quantas queries o servidor faz.
Para join, agregação ou transação de várias etapas, o driver continua exposto — a abstração cobre o comum e não finge cobrir o resto.
Produção — o que o servidor já trata
- Violação de constraint vira o status certo. E-mail duplicado responde
409, chave estrangeira inexistente 422 — não mais 500 "Erro interno".
O tratamento está no
createLeveServer, então toda rota escrita à mão herda isso sem precisar lembrar. Verdb/errors.ts: reconhece SQLSTATE (Postgres), número do fabricante (MySQL, SQL Server) e mensagem (SQLite, Oracle). A mensagem devolvida nunca inclui o SQL nem o valor enviado. - Request ID. Toda requisição ganha um id, ecoado em
X-Request-Ide presente em cada linha de log. ComtrustProxy, respeita oX-Request-Idrecebido (validado contra injeção de log) para a correlação atravessar serviços. - Timeouts explícitos.
requestTimeout30s,headersTimeout15s (defesa contra slowloris),keepAliveTimeout5s. Os defaults do Node são pensados para upload longo, não para API JSON. - Guardas de processo.
installProcessGuardsregistrauncaughtException/unhandledRejectionem JSON e encerra com shutdown gracioso — continuar rodando depois de uma exceção não capturada é como um serviço passa a corromper dado em silêncio. - Escopo de autorização no CRUD.
scoperestringe todas as operações ao que a requisição pode ver;beforeCreatecarimba o dono na escrita:
model.resource({
scope: (req) => ({ autor_id: req.user.sub }),
beforeCreate: (values, req) => ({ ...values, autor_id: req.user.sub }),
});Registro de outro dono responde 404, não 403 — 403 confirmaria que ele existe, o que permite enumerar ids alheios contando as respostas.
Segurança — o que o caminho integrado monta
O caminho oficial para novas aplicações usa os presets createAuthModule() e
createCommercialModule(), já montados pelo CLI. O contrato HTTP, a proteção
default-deny e o bilhete
WebSocket de uso único estão documentados em
AUTH.md; catálogo, billing e entitlement estão em
COMMERCIAL.md.
Billing recorrente, pagamentos avulsos, analytics e busca externa compartilham
um registro opt-in com diagnóstico sem credenciais. Os contratos, limites de
responsabilidade do adapter e requisitos de isolamento/idempotência estão em
INTEGRATIONS.md.
A composição completa de core, auth, realtime, commercial e
observabilidade, com as opções seguras e a ordem dos módulos, está em
PRESETS.md. O CLI gera os quatro primeiros diretamente pelos
entrypoints públicos; métricas e diagnóstico ficam opt-in porque exigem uma
política operacional de acesso.
Os parsers próprios de PostgreSQL, Redis, WebSocket, WebAuthn/CBOR e multipart
possuem corpus mutacional reproduzível. Execute npm run fuzz; orçamento,
reprodução por seed e política de regressão estão em fuzz/README.md.
Models, migrations, rotas, jobs, canais, webhooks e checks são inventariados
uma vez com defineLeveApplication(). API e worker consumindo o mesmo manifesto
estão documentados em APPLICATION.md.
- SQL injection: impossível pelo desenho —
pool.query(sql, params)usa sempre o protocolo estendido do Postgres (Parse/Bind/Execute), nunca interpolação de string. Nomes de tabela/coluna são validados contra uma allowlist ([A-Za-z_][A-Za-z0-9_]*) na definição do recurso. - Mass assignment:
defineResourcesó aceita, noPOST/PATCH, as colunas declaradas e não-readOnly— qualquer campo extra no body é ignorado silenciosamente. - DoS por payload:
readJsonBodytem limite de tamanho checado incrementalmente durante a leitura (não só no final). - Rate limiting: o core limita por IP e o preset de auth monta
compositeRateLimit()por IP, usuário, tenant e operação. - CORS: allowlist explícita de origens — nunca reflete
*comcredentials: true. - Cabeçalhos de segurança:
securityHeaders()—X-Frame-Options,X-Content-Type-Options, CSP restritiva, HSTS, etc. - Senhas: o fluxo HTTP usa
hashPasswordAsync/verifyPasswordAsynccomscrypt, sem bloquear o event loop; há salt aleatório, comparação em tempo constante, limite de tamanho e rate limit nos endpoints de auth. - Sessão integrada: access token curto em memória + refresh rotativo só em
cookie
HttpOnly; Secure; SameSite=Strict. O backend detecta reuso e revoga a sessão; o cliente Leve compartilha uma única rotação entre 401 concorrentes. - PostgreSQL: TLS por padrão (
ssl: true), autenticação SCRAM-SHA-256 com verificação mútua (o cliente confirma a assinatura do servidor, não só o contrário — protege contra servidor forjado/MITM). Servidores que ainda exigem o mecanismo MD5 legado são recusados com instruções de migração.
Limitações conhecidas (documentadas, não escondidas)
rateLimitusa memória (por processo) por padrão. ComREDIS_URLdefinido o contador passa a ser compartilhado — ver Rate limit abaixo.- A verificação de attestation do WebAuthn cobre apenas
fmt: "none"(o padrão, e o que passkeys usam na prática). Formatos com declaração de procedência do autenticador —packed,tpm,android-key— são recusados em vez de aceitos sem verificar: se a attestation não é checada, é melhor falhar alto do que dar a impressão de que foi. int8do Postgres viraNumberquando cabe emNumber.MAX_SAFE_INTEGER, senão fica comostring(limitação normal de JS, não do driver).
Motores internos (sem suporte operacional isolado)
Os contratos abaixo existem para testes, migração e evolução interna. A distribuição suportada do Leve continua sendo PostgreSQL + Redis dentro da stack completa; combinar estes adaptadores com outro backend/frontend/infra não faz parte da matriz de suporte do produto.
A escolha do banco não vaza para o código da aplicação. defineResource
continua recebendo a mesma declaração de colunas; o que muda é uma linha:
// PostgreSQL — o caminho original, inalterado
defineResource({ pool, table: 'posts', columns });
// SQLite — sem dependência e sem serviço (node:sqlite vem no Node)
defineResource({ driver: new SqliteDriver('./dados.db'), table: 'posts', columns });
// MongoDB, Elasticsearch, Redis, ou o seu — qualquer DataStore
defineResource({ store, table: 'posts', columns });São duas abstrações, e a razão de serem duas importa. Dialect
(db/dialect.ts) cobre as diferenças entre bancos relacionais —
placeholder, janela de resultados, RETURNING, DDL transacional, lock
cooperativo. Mas MongoDB e Elasticsearch não têm SQL nenhum, e o Redis não tem
o conceito de listar com filtro. Por isso o contrato que o defineResource
consome é o DataStore (store/types.ts) — list/get/create/update/
remove — e o SQL é apenas uma das implementações.
| Banco | Como | Observação |
|---|---|---|
| PostgreSQL | Driver próprio | Wire protocol, SCRAM-SHA-256 e TLS escritos aqui. Padrão e alvo principal. |
| SQLite | node:sqlite embutido | Zero dependência, zero protocolo. Node 24+ (experimental no 22.5+). |
| Redis | Driver próprio | RESP2 escrito aqui. Como store, serve coleções pequenas — sem índice secundário. |
| Elasticsearch | HTTP próprio | Não tem protocolo de fio: JSON sobre HTTP. Vale igual para OpenSearch. |
| MySQL / MariaDB | Adaptador | Dialeto pronto. Driver próprio é viável — próximo candidato natural. |
| SQL Server | Adaptador | Dialeto pronto (OUTPUT, OFFSET..FETCH). TDS é grande demais para reimplementar com ganho. |
| Oracle | Adaptador | Dialeto pronto. Oracle Net é proprietário e não documentado. |
| MongoDB | Adaptador | Exigiria BSON inteiro mais OP_MSG. O contrato é uma coleção do driver oficial. |
| Outro | DataStore próprio | Cinco métodos. createMemoryStore é a implementação de referência. |
"Adaptador" significa ExternalSqlDriver (ou um create*Store): a dependência
fica no package.json da aplicação, e o framework continua com zero
dependências de runtime.
const driver = new ExternalSqlDriver({
dialect: 'mysql',
query: async (sql, params) => {
const [rows] = await pool.query(sql, params);
return { rows: Array.isArray(rows) ? rows : [], rowCount: rows.affectedRows };
},
close: () => pool.end(),
});Migrations portáveis
Migration.statements aceita uma função do dialeto. dialect.types cobre
o que muda de nome em cada banco, e createTable/createIndex cobrem o
IF NOT EXISTS, que metade deles não tem:
export const initialSchema: Migration = {
name: '001-initial-schema',
statements: (d) => [
d.createTable('users', `
id ${d.types.autoIncrementPk},
email ${d.types.varchar(200)} NOT NULL UNIQUE,
password_hash ${d.types.text} NOT NULL,
created_at ${d.types.timestamp} NOT NULL DEFAULT ${d.types.now}
`),
d.createIndex('posts_author_id_idx', 'posts', 'author_id'),
],
};A mesma migration vira serial PRIMARY KEY no Postgres, integer PRIMARY KEY
AUTOINCREMENT no SQLite, int AUTO_INCREMENT PRIMARY KEY no MySQL,
int IDENTITY(1,1) no SQL Server e GENERATED BY DEFAULT AS IDENTITY no
Oracle. As migrations do projeto (001 e 002) já são assim, e há teste
aplicando-as num SQLite real.
Uma regra que não é óbvia: coluna que entra em chave ou índice usa
types.varchar(n), nunca types.text. O MySQL não indexa TEXT sem
comprimento de prefixo, então um text PRIMARY KEY que funciona no Postgres
nem cria a tabela lá.
O que não é portável — e é dito, não escondido
- MySQL e Oracle fazem commit implícito em DDL. Um erro no meio de um conjunto de migrations não reverte as anteriores. O runner avisa no console em vez de prometer atomicidade que o banco não entrega.
- Oracle não recebe lock cooperativo (
DBMS_LOCKexige grant e alocação prévia): aplique migrations com uma instância por vez. O SQLite também não recebe, mas ali não faz falta — ele serializa escritas por natureza. - O
DataStorecobre CRUD, não joins, agregações ou transações de várias etapas. São coisas que os bancos resolvem de formas incompatíveis demais para uma abstração honesta — quem precisa usa o driver direto, que continua exposto. SqliteDriverexige Node 24+ (ou 22.5 com--experimental-sqlite). ODockerfiledo backend usanode:24-alpinepor isso.
Migrations
npm run migrate aplica as migrations pendentes; o docker-entrypoint.sh
roda isso antes de subir o servidor (com retry, porque depends_on garante
ordem de início mas não readiness do Postgres). Tudo acontece numa única
transação com pg_advisory_xact_lock, então várias instâncias subindo em
paralelo não competem, e um erro no meio reverte o conjunto inteiro.
Migrations são listas de statements em TypeScript (migrations/00N-*.ts),
não arquivos .sql: Connection.query() usa sempre o protocolo estendido do
Postgres, que aceita um único statement por Parse — aceitar um .sql com
vários comandos exigiria quebrar o texto no ;, e fazer isso direito
significa entender string literals, dollar-quoting e comentários. Declarar os
statements elimina esse parser.
Rate limit
O contador tem duas implementações atrás da mesma interface, e o alvo de deploy decide qual usar — o código da aplicação é o mesmo nos dois casos:
// Instância única (VPS simples, HTTP básico): memória, mais rápido.
router.use(rateLimit({ max: 300, windowMs: 60_000 }));
// Várias réplicas (cloud): contador compartilhado, o limite vale de verdade.
const redis = new RedisClient(parseRedisUrl(process.env.REDIS_URL));
router.use(rateLimit({ max: 300, windowMs: 60_000, store: new RedisRateLimitStore(redis) }));No caminho oficial, createAuthModule() acrescenta camadas por usuário,
tenant e operação depois de validar a sessão. Para composição manual:
router.use(compositeRateLimit({
store: new RedisRateLimitStore(redis),
bucket: 'business-api',
policies: {
ip: { max: 300, windowMs: 60_000 },
user: { max: 600, windowMs: 60_000 },
tenant: { max: 3000, windowMs: 60_000 },
operation: { max: 120, windowMs: 60_000 },
},
}));operation combina método, padrão declarado da rota e principal corrente. IDs
concretos no path não criam chaves, e IP/subject/tenant chegam ao store somente
como digest. RedisRateLimitStore.hitMany() envia todas as dimensões em uma
pipeline.
Sem store compartilhado, o limite efetivo num deploy escalado vira
max × nº de instâncias. O server.ts escolhe automaticamente:
Redis quando REDIS_URL existe, memória quando não.
Dois detalhes que não são óbvios:
- Cada
rateLimit()precisa do seubucket. Ao compartilhar um store, limitadores com chave só de IP somariam no mesmo contador — o limite global de 300/min faria o de login (10/min) disparar na primeira tentativa. O padrão deriva demax+windowMs; informebucketexplicitamente quando dois limitadores tiverem a mesma configuração. - Degradação local, não falha aberta. Se o store cair, o erro é logado com teto de frequência e a contagem continua em memória por instância. A API permanece disponível sem remover toda a proteção contra brute force.
O cliente Redis é próprio (redis/), falando RESP2 direto por TCP/TLS —
mesma escolha do driver Postgres, sem dependência de terceiros. Comandos vão
no formato unificado (array de bulk strings com comprimento declarado), então
um valor com CRLF não consegue virar um comando extra.
Passkeys (WebAuthn)
Os três endpoints que o <l-passkey-button> do frontend espera são montados
automaticamente pelo createAuthModule({ passkey: { ... } }) (e continuam
disponíveis no servidor legado):
GET /api/auth/passkey/options— emite um challenge de uso único e o guarda (?purpose=registerexige sessão: uma passkey sempre se vincula a uma conta existente).POST /api/auth/passkey/register— verifica a cerimônia e guarda só a chave pública.POST /api/auth/passkey/login— verifica a assinatura da assertion e devolve um token de sessão.
O challenge é de uso único mesmo sob requisições concorrentes — é essa
amarração que impede replay. Quem cuida disso é o createChallengeStore
(webauthn/challenge-store.ts), e o mecanismo muda com o banco: onde há
RETURNING/OUTPUT, um DELETE só resolve e diz quem ganhou a corrida; onde
não há (MySQL, Oracle), o user_id vem de um SELECT mas quem arbitra é a
contagem de linhas do DELETE — duas requisições podem ler ao mesmo tempo,
só uma remove. Confiar no SELECT ali reintroduziria o replay, e passaria em
qualquer teste sequencial.
O prazo de validade é calculado no processo e enviado como parâmetro, em vez
de now() + interval '5 minutes': além de remover a sintaxe de intervalo (que
muda em todo banco), mantém emissão e verificação no mesmo relógio. A verificação de assinatura usa a chave pública registrada sobre
authenticatorData || SHA-256(clientDataJSON), e o contador de assinatura é
conferido para detectar credencial clonada (autenticadores que não
implementam contador mandam 0 sempre, e aí não há o que comparar).
SSO por OIDC
Entrar com o provedor de identidade da empresa — Entra ID, Okta, Google
Workspace, Keycloak, Auth0. O fluxo é Authorization Code com PKCE, o único
recomendado pelo OAuth 2.1; implicit e hybrid ficam de fora porque devolvem
token na URL, e URL vai para histórico, log de proxy e Referer.
import { createOidcStore, oidcMigration } from '@leve-framework/backend';
const sso = createOidcStore({
driver,
providers: [{
id: 'empresa',
issuer: 'https://login.microsoftonline.com/<tenant>/v2.0',
clientId: process.env.OIDC_CLIENT_ID!,
clientSecret: process.env.OIDC_CLIENT_SECRET!,
redirectUri: 'https://app.exemplo.com/api/auth/sso/callback',
emailDomains: ['empresa.com'], // home realm discovery
}],
});Duas rotas fecham o fluxo. Com createAuthModule({ oidc: { enabled: true,
providers, provisionUser } }) elas já vêm montadas; a decisão de o que fazer
com uma identidade nova continua sendo da aplicação, via provisionUser.
router.get('/api/auth/sso/:provedor', async (req, res) => {
const { url } = await sso.begin(req.params.provedor, { returnTo: '/painel' });
res.status(302).header('location', url).send('');
});
router.get('/api/auth/sso/callback', async (req, res) => {
const { identity, userId, returnTo } = await sso.complete(req.query);
// Primeiro login: provisionar, convidar ou recusar — política da aplicação.
const id = userId ?? await provisionar(identity);
if (!userId) await sso.link(identity, id);
const par = await sessions.issue(id, { ip: req.ip });
res.status(302).header('location', returnTo ?? '/').send('');
});A identidade é (issuer, sub) — nunca o e-mail. É a decisão que separa
isto de um sequestro de contas: o sub é o único identificador que o OIDC
promete estável dentro do emissor, enquanto e-mail muda, é reciclado quando
alguém sai da empresa e, em alguns provedores, pode ser declarado pelo
usuário. Casar contas por e-mail significa que quem consegue um email
arbitrário num IdP qualquer entra na conta de outra pessoa — e o fluxo feliz
continua funcionando, então nada denuncia a falha. Por isso identity.email
vem só como rótulo de tela, e emailVerified chega false quando o provedor
não afirma ter verificado.
O que é conferido no ID token, e o que cada checagem impede, está na tabela do
cabeçalho de auth/oidc.ts. Os pontos que mais costumam faltar em
implementação caseira:
algrestrito a assimétricos.noneaceita token sem assinatura, e a família HMAC permite ao atacante assinar com a chave pública do JWKS — que é pública. Recusar a família inteira torna a confusão impossível em vez de improvável.azpquando há mais de umaud. Sem isso, o aplicativo vizinho no mesmo tenant do IdP reaproveita aqui um token que o usuário deu para ele.issuerdo documento de descoberta igual ao configurado. Uma descoberta redirecionada faria todas as checagens seguintes validarem contra o IdP errado.- Rebusca do JWKS com intervalo mínimo. Rotação de chave é rotina e o
kidnovo chega antes de o cache vencer, então rebuscar é obrigatório — mas sem intervalo mínimo um token comkidaleatório vira uma requisição ao IdP, e mil deles, uma negação de serviço contra o sistema do qual todas as aplicações da empresa dependem.
O state é consumido uma vez só, arbitrado pela contagem de linhas do
DELETE — mesma mecânica do challenge de WebAuthn. É o que barra o replay do
código e o CSRF de login (induzir a vítima a completar um fluxo iniciado pelo
atacante, deixando-a logada na conta dele). O returnTo só aceita caminho
relativo: //evil.com é URL absoluta para o navegador e passa em qualquer
validação que só cheque "começa com /".
Grupos e papéis do IdP não viram permissão sozinhos. identity.groups é
repassado como o provedor mandou, e o mapeamento para as policies de authz/
é escrito na aplicação. Confiar direto no nome do grupo entrega a autorização
a quem administra o diretório — que muitas vezes não é quem responde pelo
sistema.
oidcMigration e totpMigration já fazem parte de coreMigrations no projeto
gerado. Aplicações compostas manualmente devem incluí-las no manifesto caso
habilitem os respectivos recursos.
Tempo real: WebSocket e canais
Servidor WebSocket próprio (RFC 6455) na mesma porta do HTTP — o upgrade acontece na conexão que o navegador já abriu. Sem segundo servidor, sem segunda porta no firewall, com o mesmo TLS.
No caminho recomendado, createRealtimeModule() consome channels, models
e realtimeModels do manifesto. O cache compartilhado é invalidado após cada
escrita confirmada e somente bindings explícitos publicam linhas:
const realtime = createRealtimeModule({
path: '/ws',
origins: [config.frontendOrigin],
authenticate: (_request, url) => auth.authenticateWebSocket(url),
cache,
channelOptions: realtimeBus ? { bus: realtimeBus } : undefined,
});
const application = defineLeveApplication({
models: [pedidos],
channels: [{
name: 'pedidos.:id',
kind: 'private',
authorize: fromPolicy(pedidoPolicy, ({ params }) => pedidos.findOne(params.id)),
}],
realtimeModels: [{
model: pedidos,
channel: event => event.id == null ? null : `pedidos.${event.id}`,
}],
});O exemplo de baixo nível abaixo continua disponível para integrações que não usam o lifecycle da aplicação:
Tanto escritas diretas (model.update()) quanto o CRUD derivado
(model.resource()) emitem os mesmos eventos somente depois da gravação
bem-sucedida. Assim audit, cache e socket não dependem da rota usada.
import { createWebSocketServer, createChannels, broadcastModel, fromPolicy } from '@leve-framework/backend';
const canais = createChannels({
definitions: [
{ name: 'cotacoes' }, // público
{
name: 'pedidos.:id', // privado
kind: 'private',
authorize: fromPolicy(pedidoPolicy, ({ params }) => pedidos.findBy({ id: params.id })),
},
{
name: 'sala.:id', // presença
kind: 'presence',
authorize: ({ actor }) => actor && { id: String(actor.sub), info: { nome: actor.nome } },
},
],
});
const ws = createWebSocketServer({
server, // o mesmo node:http
origins: ['https://app.exemplo.com'], // obrigatório — ver abaixo
authenticate: (req, url) => bilhetes.consume(url.searchParams.get('t')),
onMessage: canais.onMessage,
onClose: canais.onClose,
});
// Escrita no modelo → evento no canal. É o que faz a lista ficar reativa.
broadcastModel(pedidos, canais, { channel: (p) => `pedidos.${p.id}` });O cliente é o mesmo dos dois lados — mora em shared/, porque um protocolo
escrito duas vezes diverge:
import { createRealtimeClient } from '@leve-framework/backend/shared';
const rt = createRealtimeClient({
url: () => pedirBilhete().then((t) => `wss://api.exemplo.com/ws?t=${t}`),
onEvent: ({ channel, event, data }) => tabela.aplicar(event, data),
});
rt.connect();
rt.subscribe('pedidos.7');A checagem de origem é obrigatória, e é o ponto
Um WebSocket não é protegido pela política de mesma origem. Qualquer página, em qualquer domínio, pode abrir um socket para o seu servidor — e o navegador manda os cookies do usuário junto, sem preflight de CORS, sem nada barrar. É o Cross-Site WebSocket Hijacking: a vítima visita um site qualquer, a página abre um socket para a sua API com a sessão dela, e o atacante lê e escreve como ela em canal bidirecional.
Por isso origins não tem padrão: sem a lista, o servidor recusa. Requisição
sem Origin passa, e isso é deliberado — navegador sempre manda o
cabeçalho; quem não manda é curl ou um serviço, que forjaria qualquer coisa de
qualquer jeito. Quem protege esse caminho é o authenticate.
Sobre autenticação: a API WebSocket do navegador não deixa mandar
cabeçalho, então não há Authorization aqui. O caminho recomendado é o
bilhete de uso único — o cliente pede um bilhete curto por HTTP autenticado
e o gasta no upgrade (é o createTokenStore com um propósito novo). Token na
query string funciona e é desaconselhado: query string vai para log de proxy e
de load balancer, onde fica meses.
O que faz o servidor sobreviver em produção
Três coisas, e nenhuma é o codec de frames:
- Ping/pong. TCP não avisa quando alguém fecha a tampa do notebook: o socket fica meio aberto, o servidor acha que tem cliente e o cliente não existe mais. Sem detecção essas conexões se acumulam por dias, cada uma com sua memória e seu lugar em cada broadcast.
- Backpressure.
socket.write()volta na hora mesmo com o outro lado parado, e o que não coube fica em memória do servidor. Um cliente lento num canal movimentado é um vazamento. Aqui a fila tem teto e estourou fecha com 1013 — derrubar um cliente lento é ruim, derrubar o processo por causa dele é pior. - Recusar o que o protocolo manda recusar. Frame de cliente sem máscara, RSV ligado, controle fragmentado, texto que não é UTF-8, comprimento acima do teto: cada um fecha com o código certo, e cada um tem teste com o frame malformado correspondente.
Escala horizontal
Um WebSocket é uma conexão TCP presa a um processo. Com duas instâncias atrás de um load balancer, metade dos assinantes está em cada uma — e a escrita que aconteceu numa não alcança quem está conectado na outra. O sintoma é cruel: funciona em desenvolvimento e entrega metade das mensagens em produção.
const canais = createChannels({ definitions: [...], bus: createRedisBus({ host, port }) });O envelope carrega a instância de origem e o eco é descartado — sem isso, quem publicou entrega duas vezes, e a entrega local republicando viraria laço infinito entre os processos. A garantia é no máximo uma vez, sem histórico: pub/sub do Redis não guarda mensagem. Serve para "a lista mudou, atualize"; não serve para "o pagamento foi confirmado" — esse é trabalho da fila, que tem persistência, retry e dead-letter.
Limitações documentadas
- A autorização é verificada na inscrição, não a cada evento. Permissão
revogada depois não derruba sozinha quem já estava inscrito — quem revoga
acesso sensível chama
evict()no mesmo lugar em que revoga. Checar a cada evento seria uma consulta por mensagem por assinante. - A granularidade do canal é a granularidade da permissão. Um canal
pedidosque publica toda linha entrega a tabela inteira a quem se inscreveu uma vez. Por issobroadcastModelexige o nome do canal em vez de assumir um padrão cômodo. - Presença + barramento é recusada por padrão. A lista de membros que o
recém-chegado recebe teria só os do processo dele.
allowLocalPresence: trueaceita isso conscientemente; presença distribuída de verdade precisa de registro compartilhado com TTL e heartbeat, que não está construído.
SSE e resposta em streaming
O WebSocket é bidirecional e paga por isso. Muita coisa em tempo real só precisa de uma direção — progresso de relatório, log de deploy, notificação — e para essas o SSE entrega sobre HTTP comum, com uma vantagem que se esquece de citar: o navegador reconecta sozinho e manda de volta o último id recebido, o que permite ao servidor retomar em vez de recomeçar.
router.get('/api/pedidos/eventos', requireAuth(SECRET), async (req, res) => {
const fluxo = sse(req, res);
const desligar = canais.subscribe?.(...); // ou sseFromChannel(fluxo, ...)
fluxo.onClose(desligar);
});Três coisas fazem SSE funcionar na máquina do desenvolvedor e falhar em produção, e as três já vêm tratadas:
- O proxy que bufferiza. Nginx bufferiza resposta de upstream por padrão, e
uma resposta SSE não termina — então nada chega, sem erro nenhum. O
X-Accel-Buffering: novai por padrão porque quem precisa dele não sabe que precisa. - O proxy que fecha conexão ociosa. Um fluxo de eventos raros é uma
conexão ociosa, e load balancer costuma matar em 60s. O heartbeat manda um
comentário a cada 15s — tráfego para o proxy, invisível para o
EventSource. - O limite de ~6 conexões por origem em HTTP/1.1. Cada aba com SSE aberto consome uma permanentemente; seis abas travam todas as outras requisições do site. Sobre HTTP/2 o limite não existe.
Para exportar muita linha, streamNdjson escreve conforme a fonte produz:
router.get('/api/relatorio.ndjson', async (req, res) => {
await streamNdjson(res, paginate((page, size) => pedidos.find({ page, pageSize: size })), {
filename: 'pedidos.ndjson',
});
});res.json(await tudo()) precisa de duas cópias do resultado inteiro em
memória — o array e a string do stringify. Uma exportação de 300 MB vira 600 MB
no pico, por requisição; dez pedidos simultâneos derrubam o processo com um
heap out of memory que não menciona a rota nem o usuário. Escrevendo linha a
linha, o pico é o tamanho de uma linha.
NDJSON antes de um array JSON por dois motivos concretos: o cliente processa
enquanto recebe (um array só pode ser parseado depois do último byte, e o
problema volta do outro lado), e uma falha no meio é detectável — as linhas
anteriores continuam válidas e o erro é uma linha a mais. streamJsonArray
existe para quem precisa do formato antigo, com o aviso de que o corte no meio é
indistinguível de um JSON truncado.
CSV também permanece incremental. Para qualquer driver, streamCsv recebe os
registros e uma allowlist ordenada de colunas. No PostgreSQL, o caminho mais
rápido evita até a criação dos objetos JavaScript: COPY TO STDOUT segue como
bytes diretamente para o socket HTTP.
router.get('/api/pedidos.csv', async (_req, res) => {
await streamBytes(
res,
copyToCsv(pool, {
table: 'pedidos',
columns: ['id', 'cliente', 'total'],
timeoutMs: 0,
}),
{ contentType: 'text/csv; charset=utf-8', filename: 'pedidos.csv' },
);
});copyFromCsv faz a direção inversa sem carregar o upload inteiro. Em rotas de
upload, defina maxBytes; exceder o teto envia CopyFail e o PostgreSQL desfaz
a instrução. COPY grava direto na tabela e, por isso, não executa validação,
eventos, auditoria nem invalidação do modelo. Consulte o
guia de COPY e CSV.
Webhooks de saída
Entregar evento ao sistema de outra empresa, pela fila — o que dá retry com backoff, dead-letter e reenvio manual sem escrever nada disso de novo. Entregar de dentro do request handler prenderia a resposta do usuário na lentidão de um servidor de terceiro.
const operations = createOperationsModule({
driver,
mailer,
webhooks: {
signingKey: config.webhooks.signingKeys?.current ?? config.webhooks.signingKey!,
previousSigningKeys: config.webhooks.signingKeys?.previous ?? [],
},
});
const runtime = createWorkerRuntime({
driver, application, mailer,
webhooks: {
signingKey: config.webhooks.signingKeys?.current ?? config.webhooks.signingKey!,
previousSigningKeys: config.webhooks.signingKeys?.previous ?? [],
},
});
const hooks = operations.webhooks!;
const { secret } = await hooks.register({ url, events: ['pedido.criado'] });
await hooks.dispatch('pedido.criado', { id: 42 });No projeto gerado, operations e runtime já são montados pelos entrypoints
oficiais. Serviços, rotas e jobs importam operations, storage ou mailer
de backend/operations.ts; não é necessário remontar esses recursos. Habilite
o dispatcher com LEVE_WEBHOOKS_ENABLED=true e uma chave
LEVE_WEBHOOK_SIGNING_KEY de pelo menos 32 caracteres. Em produção sem SMTP,
o mailer permanece desligado em vez de registrar destinatários e conteúdo.
O timestamp faz parte do que é assinado. Leve-Signature: t=…,v1=… é
HMAC-SHA256(segredo, "<t>.<corpo>"). Assinar só o corpo — o que mais se vê por
aí — deixa o replay livre para sempre: quem capturou uma entrega legítima
reenvia com assinatura perfeitamente válida. O destinatário recalcula sobre o
corpo cru (não o resultado de parse + stringify, que muda espaçamento e
ordem de chaves), compara em tempo constante e recusa t fora de uma janela. O
código que ele deve escrever é o verifySignature exportado daqui.
Durante a rotação da chave mestra, o cabeçalho carrega um v1= para o segredo
atual e um para cada anterior. verifySignature() aceita qualquer assinatura
válida sem afrouxar corpo ou timestamp. Novos endpoints e secretFor() usam
somente a mestra atual; retire cada anterior depois de todos os destinatários
confirmarem o novo segredo derivado.
Nenhum segredo fica no banco. O segredo de cada endpoint é derivado —
HMAC(chaveMestra, "<id>:<versão>") — e a tabela guarda só a versão. Um
vazamento não entrega segredo de assinatura nenhum, e a rotação é incrementar um
inteiro. O caminho comum, guardar em texto "porque precisa dele para assinar",
cria uma tabela cujo vazamento permite forjar eventos para todos os clientes.
SSRF é o risco do módulo, porque a URL é escolhida por quem se cadastrou. Um
servidor que faz requisição para onde mandarem é um proxy para dentro da rede:
http://169.254.169.254/ entrega as credenciais IAM da instância,
http://localhost:6379/ fala com o seu Redis. A defesa é exigir HTTPS, resolver
o DNS e recusar endereço privado/loopback/link-local — todos os endereços do
nome, não só o primeiro — e não seguir redirecionamento, porque um 302
desfaria tudo isso. Fica registrado o limite: entre a checagem e a conexão há
uma janela de DNS rebinding que só um proxy de egresso com allowlist fecha.
Endpoint que falha muitas vezes seguidas é desativado — e a contagem só avança na última tentativa, senão uma indisponibilidade de poucos minutos desativaria um cliente saudável.
Estrutura
backend/
db/
protocol.ts — codec do wire protocol (buffers, framing de mensagens)
scram.ts — autenticação SCRAM-SHA-256 (RFC 5802/7677)
types.ts — decodifica valores em formato texto do Postgres
connection.ts — uma conexão TCP/TLS: startup, auth, queries
pool.ts — pool de conexões
http/
router.ts — Router com :param, middlewares, dispatch
body.ts — leitura de corpo JSON com limite de tamanho
security.ts — securityHeaders / cors / rateLimit
server.ts — node:http + integração com o Router
resource.ts — defineResource(): gera o CRUD REST
auth.ts — hash de senha, tokens de sessão, requireAuth
operations.ts — fila, e-mail, storage, webhooks e audit compartilhados
index.ts — API pública
server.ts — entrypoint da aplicação
worker.ts — fila, scheduler, manutenção e e-mail assíncrono