Backend em Node.js + TypeScript que processa DANFEs (Documento Auxiliar da Nota Fiscal Eletrônica) brasileiras: extrai dados via IA, valida contra as regras do domínio fiscal, casa itens com o catálogo existente e atualiza o estoque com auditoria completa.
Este documento descreve a arquitetura real do sistema, tal como implementada. Para o histórico de decisões e o plano de engenharia que levou a este estado, veja docs/auditoria-tecnica.md e docs/backlog-engenharia.md.
Imagem/PDF → OCR (Gemini) → Extração estruturada → Validação → Matching de produtos → Upsert → Auditoria
A IA (Google Gemini) nunca é fonte de verdade. Ela apenas extrai dados do documento e sugere similaridade entre um item da nota e produtos já cadastrados. Toda decisão que altera a identidade de um produto passa por confirmação humana antes de afetar o estoque — ver Sugestões de produto abaixo.
Estrutura real de src/:
controllers/— Tradução HTTP ↔ use case. Sem lógica de negócio; lançamAppErrore deixam o handler global responder.use-cases/— Orquestração das regras de aplicação (ReadInvoiceUseCase,CreateCompanyUseCase,LoginUseCase, casos de uso de sugestão de produto, etc.).domain/— Regras de domínio puras e determinísticas (validação de CNPJ, coerência do DANFE). Sem I/O.providers/— Contratos e implementações para integrações externas:IStorageProvider(disco),IAiProvider(Gemini),IHashProvider(Argon2),ITokenProvider(JWT/jose).repositories/— Gateways de dados: interface + implementação Prisma + dublê in-memory (para testes) de cada agregado (User, Company, Stock, Product, AuditLog, ProductSuggestion, invoice persistence).mappers/— Tradução explícita entre o tipo gerado pelo Prisma e o tipo de domínio (ProductMapper,CompanyMapper,StockMapper,AuditLogMapper). Nenhum repositório Prisma faz cast (as) direto do retorno do client para o tipo de domínio.middlewares/— Autenticação, rate limiting, validação declarativa (zod), request ID, cabeçalhos de segurança, error handler global.schemas/— Schemaszodpara corpo de requisição HTTP e para a resposta estruturada do Gemini.infra/— Cliente Prisma único, logger estruturado, telemetria de IA, healthcheck.errors/—AppError(estendeError) e tradução de códigos de constraint do Prisma (P2002/P2003) para status HTTP.config/— Configuração centralizada e validada (env.ts), upload (multer),trust proxy.
Não existe camada de entities/ nem container de DI — ver Decisões arquiteturais deliberadas.
Todas as respostas seguem o envelope { status: 'success', data } ou { status: 'error', message }. Erros de negócio usam AppError e são traduzidos pelo error handler global para o status HTTP correspondente — nenhuma rota faz comparação de string de mensagem para decidir status.
| Método | Rota | Autenticação | Descrição |
|---|---|---|---|
GET |
/health |
pública | SELECT 1 real no Postgres; 503 se o banco estiver indisponível ou o processo em shutdown. |
POST |
/users |
pública, rate limit 5/hora por IP | Cadastro de usuário. Exige inviteCode correto (coorte controlada do piloto); ausente ou incorreto devolve o mesmo 403 genérico. Senha com Argon2. |
POST |
/login |
pública, rate limit 10/15min por IP | Autenticação; devolve JWT. |
POST |
/companies |
Bearer JWT, rate limit 5/min por usuário | Cria empresa + estoque principal em uma transação atômica. |
GET |
/companies |
Bearer JWT | Lista paginada (cursor) das empresas acessíveis ao usuário — owned e onde ele é colaborador —, com o papel (OWNER/COLLABORATOR) de cada uma. |
GET |
/companies/:companyId/stocks |
Bearer JWT | Lista paginada (cursor) dos estoques visíveis da empresa. Owner vê todos; colaborador só os que têm StockPermission.canView = true. 404 (sem distinguir "não existe" de "sem acesso") se o usuário não tiver nenhuma relação com a empresa. |
GET |
/products |
Bearer JWT | Lista paginada (cursor) por stockId. Exige acesso ao estoque. |
POST |
/invoices/upload |
Bearer JWT, rate limit 5/min por usuário | Upload de DANFE (JPEG/PNG/PDF, até 10 MiB, até DANFE_MAX_ITEMS linhas de produto). Dispara o pipeline completo. 422 se exceder o teto de itens, 503 se o matching ficar indisponível — nada é gravado e a nota pode ser reenviada. |
GET |
/stocks/:stockId/suggestions |
Bearer JWT | Lista sugestões de produto pendentes do estoque. |
POST |
/suggestions/:suggestionId/confirm |
Bearer JWT | Confirma uma sugestão: aplica a entrada no produto sugerido. |
POST |
/suggestions/:suggestionId/reject |
Bearer JWT | Rejeita uma sugestão: cadastra o item como produto novo. |
PATCH |
/me/password |
Bearer JWT, rate limit 5/min por usuário | Troca a própria senha (currentPassword/newPassword). 204 sem corpo. Incrementa authVersion na mesma escrita do novo hash — todo token emitido antes da troca deixa de ser aceito, imediatamente e sem depender de comparação de timestamp. |
Bearer JWT (Authorization: Bearer <token>), verificado via jose. req.user só existe depois do middleware de autenticação — é undefined em /login, /users e em qualquer ponto anterior a ele (refletido no tipo: Express.Request.user?: { id: string }).
Autorização é sempre derivada do recurso, nunca do corpo/query da requisição: companyId de um upload de invoice vem do stockId autorizado, não de um campo enviado pelo cliente. Owner da empresa tem acesso implícito a todos os estoques; colaboradores precisam de StockPermission explícita (canView/canCreate) por estoque.
multervalida tamanho (10 MiB) e MIME (image/jpeg,image/png,application/pdf) antes do controller.- Autorização de acesso ao estoque acontece antes de qualquer I/O — inclusive antes da leitura do arquivo do disco.
IStorageProvider.readFilelê os bytes (Buffer);IAiProvider.extractDanfeDatarecebe conteúdo + mimetype, nunca um caminho de arquivo.- Gemini extrai os dados com timeout de 30s, no máximo 2 tentativas (retry só para
429/5xx/erro de rede), e a resposta é validada por schemazod— nada do modelo é confiado sem validação de tipo/estrutura. - Coerência do DANFE é verificada (soma dos itens vs. total declarado, tolerância
max(R$0,02, 1%)) antes de qualquer persistência. - Matching: item com código exato atualiza o produto automaticamente. Sem código exato, um pré-filtro determinístico (léxico, sem IA) reduz o catálogo a no máximo 15 candidatos; só então o Gemini é chamado para similaridade — nunca com o estoque inteiro.
- O resultado da similaridade tem três estados distinguíveis pelo tipo —
match,no_matcheunavailable— e nuncanull. Ver Indisponibilidade do matching abaixo. - Toda a persistência de uma nota (upsert de produtos com custo médio ponderado, criação de sugestões,
ProcessedInvoice) acontece em uma única transação Postgres. Reenvio da mesma chave de acesso é idempotente (409em duplicata).
IAiProvider.findSimilarProduct devolve uma união discriminada, nunca null:
| Estado | Significado | Efeito |
|---|---|---|
match |
o modelo respondeu e indicou um candidato da lista fornecida, com confiança ≥ limiar | vira ProductSimilaritySuggestion pendente |
no_match |
o modelo respondeu validamente que não há equivalente, ou a confiança ficou abaixo do limiar, ou não havia candidato | item é cadastrado como produto novo |
unavailable |
não há evidência confiável para concluir nada | a nota inteira é abortada, sem persistir |
unavailable cobre timeout, falha 5xx do provedor, JSON inválido, resposta fora do schema e ID de candidato fora da lista enviada (alucinação — a defesa contra prompt injection continua recusando o ID; o que mudou é a conclusão).
Falha técnica da IA não é evidência de que o item seja novo. Quando ocorre unavailable, a nota é abortada antes da transação de persistência e a requisição responde 503. Nada é gravado — nem produto, nem sugestão, nem ProcessedInvoice —, então a chave de idempotência não é consumida e a mesma DANFE pode ser reenviada quando o provedor voltar. O 503 é deliberadamente distinto do 400/422 de documento inválido: ali o problema é o documento, aqui o documento pode estar perfeito.
Quando a IA identifica um candidato por similaridade (confiança configurável via SIMILARITY_CONFIDENCE_THRESHOLD, default 0.7), o item não altera o estoque. Uma ProductSimilaritySuggestion fica PENDING até decisão humana via /suggestions/:id/confirm ou /suggestions/:id/reject:
- Confirmar aplica a entrada (custo médio ponderado) no produto sugerido.
- Rejeitar preserva o candidato e cadastra o item recebido como produto novo.
A transição PENDING → CONFIRMED|REJECTED é condicional (WHERE status = PENDING) para impedir decisão dupla sob concorrência.
Um único PrismaClient/Pool (src/infra/prisma.ts, max: 10) compartilhado por todos os repositórios — não há uma pool por repositório. Migrations em prisma/migrations/, aplicadas com prisma migrate deploy; nenhuma migration histórica é editada. Índices compostos cobrem as consultas reais paginadas/de auditoria (products(stockId, createdAt, id), audit_logs(companyId, createdAt), audit_logs(userId, createdAt), ai_call_events(createdAt)/(correlationId)/(operation, createdAt)).
- Logger estruturado (
src/infra/logger.ts) em JSON, comredactrecursivo de senha/hash/token/Authorization/API key, erequestIdde correlação em toda resposta (X-Request-Id). - Auditoria de domínio (
AuditLog) registra criação/atualização de produto — inclusive quando a origem é confirmar/rejeitar uma sugestão de similaridade (P3-00A: mesma formaPRODUCT/CREATE|UPDATEcom{quantity, unitPrice, totalPrice}, descrição própria, nunca "processado por invoice") —, criação de empresa, tentativa de acesso não autorizado e troca de senha, compreviousState/newState. Best-effort: falha ao gravar auditoria nunca reverte uma operação já persistida. - Telemetria de IA (
src/infra/ai-telemetry.ts) registra, por chamada ao Gemini, duração monotônica, tentativas, tokens reais (nunca estimados), custo estimado em nanoUSD (estimatedCostUsdNanos— nunca faturado, verDANFE_MAX_ITEMS/D4) e categoria de falha; e por sugestão, decisão e faixa de confiança. ai_call_events(P3-01,src/infra/prisma-ai-telemetry.ts) persiste cada chamada real ao Gemini em PostgreSQL, correlacionada por umcorrelationId(UUID) gerado no servidor emReadInvoiceUseCase.execute— nunca aceito do cliente, ao contrário derequestId/X-Request-Id, que é guardado só para cruzar com log.ProcessedInvoice.correlationId(nullable) liga a nota ao custo sem nunca aparecer em log. Contrato: observabilidade, nunca autoridade de gasto — escrita best-effort, fora da transação de domínio; nenhum caminho de código lêai_call_eventspara decidir se uma chamada de IA pode acontecer (isso éai_usage_ledger, P4-02). Nenhum conteúdo de documento, descrição de produto ouaccessKeyé persistido — só IDs, contadores, duração e custo estimado.userId/companyId/stockIdexistem na linha (mesmo perímetro de acesso do banco) mas são deliberadamente excluídos do canal de log estruturado (whitelist positiva, mesmo princípio deAuditLog/P3-00B).ai_suggestion_events(P3-02) é uma view SQL, não uma tabela —product_similarity_suggestionsjá guarda confidence bruta, status,decidedAt/decidedByUserId; a view só juntaProcessedInvoice.correlationIdeStock.companyIde expande cada sugestão em um eventocreated(sempre) e, quando já decidida, um segundo eventoconfirmed/rejected. Sem escrita própria: não há o que falhar nem divergir do estado que já é transacional.confidenceBucketnunca é persistido — a faixa é calculada na consulta.- Runbook de telemetria (P3-03,
docs/telemetry-runbook.md) documenta as 8 consultas de referência do piloto sobreai_call_events/ai_suggestion_events(latência, tokens/custo por nota, taxa de falha, distribuição e aceitação de confidence, chamadas de similaridade por nota), cada uma testada contra dado semeado real. Retenção deai_call_eventsem 60 dias (D2) vianpm run retention:ai-call-events(scripts/purge-ai-call-events.mjs) — script de operador, idempotente, nunca tocaAuditLognemai_usage_ledger; sem scheduler automático, o Render Free não oferece cron/one-off jobs. - Guard de orçamento Gemini (P4-02, D8,
src/providers/ai-budget-guard.ts+ai_usage_ledger) decide, antes de cada tentativa HTTP ao Gemini, se ela pode acontecer — nunca depois. Reserva atômica (reservedRequests) em escopo global e por usuário dentro de uma única transação Postgres: se qualquer um dos dois excede o teto do dia, a transação inteira é revertida (nenhum decremento manual, nenhuma reserva vazada). Após a tentativa, reconciliação move a reserva paraspentRequests/spentTokens/spentCostUsdNanosreais. Fail-closed genuíno: falha ao escrever no ledger recusa a chamada (503) — nunca um wrapper best-effort, ao contrário deai_call_events. Tetos de requisições (18/dia global, 5/dia por usuário — D8) calibrados abaixo da cota real do provedor (RPD=20); tokens deliberadamente sem teto nesta primeira versão, por ausência de dado real do piloto — apenas observados. Kill switch (GEMINI_ENABLED) separado do teto: desligado é503semRetry-After(decisão humana, sem previsão de retorno); teto atingido é503/429comRetry-Afteraté a virada do dia.429do provedor nunca dispara retry (não há como distinguir de forma confiável throttling de cota esgotada). - Reconciliação ledger × eventos (P3-04,
npm run reconcile:ai-usage [YYYY-MM-DD], sem argumento reconcilia ontem em UTC) comparaai_usage_ledger(autoritativo) contraai_call_events(observação) — somente leitura, nunca corrige nada. A grandeza comparável para requisições éSUM(ai_call_events.attempts), nãoCOUNT(*): eventos contam operações lógicas, o ledger debita por tentativa HTTP (cada retry conta). Uma tentativa recusada por cota no meio de um retry incrementa o contador de tentativas do evento antes de a reserva ser revertida — divergência de 1 unidade esperada, não anomalia. Reserva residual (reservedRequests != 0num período fechado) é sempre anomalia.
- Graceful shutdown:
SIGTERM/SIGINTdrenam requisições em voo, encerram o pool do Postgres e saem com código 0; timeout configurável força o fechamento e sai com código 1. unhandledRejection/uncaughtExceptionsão logados com contexto seguro e disparam o mesmo shutdown fatal.- CI (
.github/workflows/ci.yml, GitHub Actions,ubuntu-latest):prisma validate→prisma generate→typecheck→lint→build→ guard de artefatos (npm run verify:artifacts, falha se o build sujar o checkout) → suíte unitária → gate de integração PostgreSQL real (Docker); job separado valida o build de produção com apenasdependenciesinstaladas (npm prune --omit=dev) e rodascripts/verify-production-runtime.mjs, que importa o grafo de dependências real do artefato compilado.
Ver .env.example. Todas são validadas e falham rápido no boot (src/config/env.ts) — nenhuma tem fallback silencioso além dos defaults documentados.
| Variável | Obrigatória | Default | Descrição |
|---|---|---|---|
DATABASE_URL |
sim | — | Postgres. |
JWT_SECRET |
sim | — | Assinatura dos tokens (HS256). Mínimo de 32 caracteres — o boot falha abaixo disso. Gerar com openssl rand -base64 48. |
GEMINI_API_KEY |
sim | — | Google Gemini. |
INVITE_CODE |
sim | — | Código de convite compartilhado exigido em POST /users (P4-03). Mínimo de 8 caracteres. Nunca versionado; trocar exige só reiniciar o serviço, sem novo build. |
PORT |
não | 3333 |
Porta HTTP. |
TRUST_PROXY_HOPS |
não | 0 |
Número exato de proxies reversos confiáveis (0–10). Só alterar se a API estiver atrás de proxy conhecido. |
CORS_ALLOWED_ORIGINS |
não | vazio | Allowlist HTTP(S) separada por vírgula. Sem wildcard, sem credenciais. |
REQUEST_TIMEOUT_MS |
não | 120000 |
Timeout de requisição do servidor HTTP. |
SHUTDOWN_TIMEOUT_MS |
não | 30000 |
Prazo do graceful shutdown antes de forçar. |
GEMINI_TIMEOUT_MS |
não | 30000 |
Timeout por tentativa ao Gemini. |
GEMINI_MAX_ATTEMPTS |
não | 2 |
Tentativas totais (1–2) para erro transitório. |
SIMILARITY_CONFIDENCE_THRESHOLD |
não | 0.7 |
Limiar de confiança do matching por similaridade (0–1). Fonte única usada no prompt e no código — não alterar sem dado real de uso. |
DANFE_MAX_ITEMS |
não | 100 |
Máximo de linhas de produto aceitas por DANFE (1–1000). Teto operacional do piloto (D1), não regra fiscal — fonte única usada no maxItems do prompt e na validação do schema. Acima do limite, 422 antes de qualquer chamada de similaridade. |
GEMINI_ENABLED |
não | true |
Kill switch do guard de orçamento (P4-02). false recusa toda chamada de IA com 503 sem Retry-After (decisão do operador, não janela de cota) — resto do sistema intacto. |
GEMINI_GLOBAL_REQUESTS_PER_DAY |
não | 18 |
Teto interno de requisições/dia, escopo global (P4-02, D8). Faixa 1–20 — nunca configurável igual ou acima do RPD real do provedor (20, lido no AI Studio). O teto global sempre prevalece sobre o de usuário. |
GEMINI_USER_REQUESTS_PER_DAY |
não | 5 |
Teto interno de requisições/dia por usuário (P4-02, D8) — mecanismo anti-monopolização da cota compartilhada, não uma reserva individual garantida. Faixa 1–20. |
npm test— suíte unitária (Vitest), dublês in-memory, sem rede nem Postgres real.npm run test:integration:postgres:docker— sobe um PostgreSQL 16 efêmero via Docker Compose, aplica as migrations do zero, roda os testes de integração real (transação, rollback, concorrência, constraints, idempotência) e desmonta tudo no final.npm run lint— ESLint (typescript-eslint, sem type-checking completo para evitar ruído em dublês de teste) com duas regras type-aware ligadas deliberadamente:no-floating-promiseseno-misused-promises— a classe exata de defeito que já derrubou o processo em produção antes da correção.npm run typecheck,npm run build,npm run prisma:validate,npm run verify:productioncompletam o Definition of Done local.
- Sem container de injeção de dependência. A composição de dependências é feita manualmente em
src/routes.ts. O projeto é pequeno o suficiente para que um container adicione indireção sem benefício claro. - Sem camada de
entities/. As regras de domínio que existem (CNPJ, coerência do DANFE) são funções puras emsrc/domain/; não há necessidade de objetos de entidade com identidade própria além do que os tipos de repositório (IProduct,ICompany, etc.) já expressam. - Match incerto nunca entra direto no estoque. Toda sugestão de similaridade da IA fica pendente até confirmação humana — não existe caminho de código que aplique uma sugestão automaticamente, mesmo com confiança alta.
- Indisponibilidade da IA nunca vira decisão de domínio. O resultado da similaridade é uma união discriminada de três estados, e não
null, justamente para que o compilador impeçaunavailablede ser lido comono_match. Uma falha de transporte não pode criar produto no catálogo do cliente. - Auditoria não tem endpoint de leitura HTTP hoje.
IAuditLogRepository.findByCompanyId/findByUserIdexistem, são testados e indexados, mas não há rota que os exponha — decisão deliberada de manter o escopo da API restrito ao fluxo operacional até haver necessidade real de um endpoint de auditoria. AuditLognão tem política de retenção automática. Decisão conservadora: nenhuma exclusão automática até haver requisito legal/de negócio definido para o prazo de guarda de dado fiscal.prisma.config.tsnão importasrc/config/env.ts. Faria o carregamento do módulo dispararcreateEnv(process.env)inteiro — exigindoJWT_SECRETeGEMINI_API_KEYsó para rodarprisma migrate/validate/generate(P5-01).resolveDatabaseUrl(src/config/database-url.ts) lê e valida sóDATABASE_URL.429do Gemini nunca dispara retry (P4-02). Não há como distinguir de forma confiável, no status/corpo da resposta, throttling de janela curta (retry ajudaria) de cota diária esgotada (retry só queima mais uma requisição da cota compartilhada). Comportamento conservador deliberado: nunca repetir429;500/503/erros de rede continuam com retry normal.- Tokens não têm teto interno no Modo A (D8), só requisições. O AI Studio publica RPM/TPM/RPD para
gemini-2.5-flash, mas não publica um TPD equivalente — não há de onde derivar um teto diário de tokens sem inventar uma fórmula de conversão.ai_usage_ledgerjá acumulaspentTokens/spentCostUsdNanossem impor limite, pronto para uma decisão futura com telemetria real do piloto; nenhum número foi inventado nem derivado de TPM. - Artefatos de build (
.js/.d.ts) são commitados junto do.ts. Os testes importam por caminho.js(convençãonodenext); rodenpm run buildapós editar.tsantes de commitar — ou o Vitest pode resolver o arquivo compilado desatualizado em vez do fonte, e o CI recusa o merge (npm run verify:artifacts, P5-06): o guard roda o build a partir do checkout limpo e falha se ele sujar qualquer artefato versionado, usandogit status --porcelaincomo fonte de verdade em vez de uma lista manual de arquivos.
- Não há Dockerfile de produção — o build de produção é validado no CI instalando só
dependencies, mas não há imagem publicada. - A otimização de lote da chamada de similaridade (M5-02 do backlog) está bloqueada por ausência de dado real de uso — o sistema ainda não foi implantado em produção.
TRUST_PROXY_HOPSeCORS_ALLOWED_ORIGINStêm defaults seguros para instância única sem proxy; ajustar antes de colocar atrás de load balancer/CDN.
npm ci
cp .env.example .env # preencher DATABASE_URL, JWT_SECRET (>= 32 caracteres, ex.: `openssl rand -base64 48`), GEMINI_API_KEY, INVITE_CODE (>= 8 caracteres)
npm run prisma:generate
npm run dev # tsx watch, recarrega em mudançasPara rodar a suíte de integração contra Postgres real é necessário Docker (docker-compose.test.yml sobe um banco efêmero isolado, nunca a DATABASE_URL de desenvolvimento).
Status: primeiro deploy real tentado em 2026-09-02 e FALHOU no build (tsc — TS2305, módulo @prisma/client sem os membros gerados do schema). Causa raiz confirmada por reprodução em checkout limpo: o build command documentado nunca executava prisma generate; sem isso, @prisma/client é só o pacote-placeholder (export * from '.prisma/client/default'), sem PrismaClient/Prisma/os tipos de modelo. Corrigido nesta rodada — ver npm run render:build abaixo — mas o deploy no Render ainda não foi reexecutado com a correção; nenhuma das afirmações abaixo foi verificada contra o ambiente real além do próprio build/migration.
- Sem Dockerfile. O runtime nativo Node do Render usa build/start commands;
npm prune --omit=dev+verify:productionjá validam o grafo de produção no CI. Um Dockerfile acrescentaria superfície sem resolver nada que o runtime nativo não resolva — revisitar só se o build vier a exigir binário de sistema. - Build command:
npm ci && npm run render:build(equivalente anpm ci && npm run prisma:generate && npm run build && npx prisma migrate deploy—prisma generateprecisa rodar antes detsc, nunca implícito emnpm ci: não hápostinstall/prepareneste projeto, por decisão deliberada, para não exigirDATABASE_URLem todonpm install). Guardado contra regressão por um job de CI dedicado (render-build-command, ver.github/workflows/ci.yml) que roda esse exato comando a partir de um checkout novo, contra Postgres efêmero. - Start command:
DATABASE_URL=$DATABASE_URL_POOLED npm start— reatribuiDATABASE_URLsó para o processo em execução; a variávelDATABASE_URLconfigurada no painel do Render permanece a direta (usada pelo build/migration acima). - Migration no build, não em Pre-Deploy Command (exclusivo de planos pagos no Render). Efeito:
prismaexiste no momento do build (antes donpm prune), o que resolve o problema de empacotamento sem exigir Dockerfile — mas a migration roda antes da nova versão entrar em tráfego e não é desfeita por rollback. - Política expand/contract é obrigatória, não recomendada, por causa do ponto acima: toda migration precisa ser compatível com a versão anterior da aplicação, porque essa versão pode voltar a rodar contra o schema novo após um rollback. Verificado: nenhuma das migrations existentes viola isso.
prisma.config.tssó exigeDATABASE_URL(nãoJWT_SECRET/GEMINI_API_KEY) — decoupling deliberado desrc/config/env.ts, cujo carregamento do módulo dispara a validação completa do ambiente. Rodar uma migration nunca deveria exigir a chave do Gemini.DATABASE_URL(painel do Render) deve usar a string direta do Neon (sem-pooler), comsslmode=require—prisma migrate deployprecisa de semântica de sessão/lock que um pooler em modo transação pode quebrar.DATABASE_URL_POOLED(variável separada, também no painel) usa a string com sufixo-pooler, e só chega à aplicação em runtime via o Start Command acima — o build/migration nunca a vê.- Segredos (
JWT_SECRET,GEMINI_API_KEY,INVITE_CODE) vão só no painel do Render — nenhum em arquivo versionado.
Fora de escopo desta preparação: provisionar a conta Render/Neon, o primeiro deploy real, e a verificação empírica de /health/SIGTERM contra o ambiente real — isso é o restante de P5-01 e depende de acesso à conta, não de código.