npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@leve-framework/backend

v1.0.5

Published

Backend do framework Leve: Node + TypeScript + PostgreSQL, sem dependências de terceiros.

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 --build

Isso 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 paginada
  • GET /users?pagination=pages&page=1&pageSize=20 — paginação de páginas
  • GET /users?pagination=infinite&page=2&pageSize=20 — lote seguinte para scroll infinito
  • GET /users/:id — um registro
  • POST /users — cria (valida campos obrigatórios, ignora campos desconhecidos/readOnly)
  • PATCH /users/:id — atualiza parcialmente
  • DELETE /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. O hasMore sai de pedir uma linha a mais que o pageSize.
  • O cursor é validado contra a ordenação. Um cursor emitido sob sort=title aplicado a sort=-created_at responde 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=3

Uma 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 down aborta 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 COLUMN desfaz 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. Ver db/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-Id e presente em cada linha de log. Com trustProxy, respeita o X-Request-Id recebido (validado contra injeção de log) para a correlação atravessar serviços.
  • Timeouts explícitos. requestTimeout 30s, headersTimeout 15s (defesa contra slowloris), keepAliveTimeout 5s. Os defaults do Node são pensados para upload longo, não para API JSON.
  • Guardas de processo. installProcessGuards registra uncaughtException/unhandledRejection em 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. scope restringe todas as operações ao que a requisição pode ver; beforeCreate carimba 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: defineResource só aceita, no POST/PATCH, as colunas declaradas e não-readOnly — qualquer campo extra no body é ignorado silenciosamente.
  • DoS por payload: readJsonBody tem 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 * com credentials: true.
  • Cabeçalhos de segurança: securityHeaders() — X-Frame-Options, X-Content-Type-Options, CSP restritiva, HSTS, etc.
  • Senhas: o fluxo HTTP usa hashPasswordAsync/verifyPasswordAsync com scrypt, 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)

  • rateLimit usa memória (por processo) por padrão. Com REDIS_URL definido 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.
  • int8 do Postgres vira Number quando cabe em Number.MAX_SAFE_INTEGER, senão fica como string (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_LOCK exige 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 DataStore cobre 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.
  • SqliteDriver exige Node 24+ (ou 22.5 com --experimental-sqlite). O Dockerfile do backend usa node:24-alpine por 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 seu bucket. 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 de max+windowMs; informe bucket explicitamente 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=register exige 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:

  • alg restrito a assimétricos. none aceita 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.
  • azp quando há mais de um aud. Sem isso, o aplicativo vizinho no mesmo tenant do IdP reaproveita aqui um token que o usuário deu para ele.
  • issuer do 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 kid novo chega antes de o cache vencer, então rebuscar é obrigatório — mas sem intervalo mínimo um token com kid aleató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:

  1. 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.
  2. 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.
  3. 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 pedidos que publica toda linha entrega a tabela inteira a quem se inscreveu uma vez. Por isso broadcastModel exige 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: true aceita 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:

  1. 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: no vai por padrão porque quem precisa dele não sabe que precisa.
  2. 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.
  3. 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