Health checks reais, cabeçalhos de segurança e Sentry por ambiente

- `/api/health/live` não toca dependência: distingue processo vivo de
  dependência fora do ar. `/api/health/ready` passou a tocar banco, migrations
  pendentes, Redis com PING, bucket com listagem, configuração e modo de job,
  e reporta em `reasons` só o nome do erro — nunca connection string.
- Configuração inválida encerra o processo. Antes o servidor ficava no ar
  respondendo 500 em todas as rotas, inclusive no liveness, enganando o
  orquestrador: pior que não subir.
- CSP, HSTS, Permissions-Policy e COOP, além dos três cabeçalhos que já
  existiam. `unsafe-inline` em script-src é exigência do bootstrap do App
  Router sem nonce; o resto é fechado e connect-src só aceita o próprio host,
  o Supabase do ambiente e o Sentry.
- `onRequestError` registrado: sem esse export os erros de rota e de render
  do servidor não chegavam ao Sentry. O ambiente do evento deriva de
  ADAPTER_ENV, então homologação e produção não se misturam.
- `withSentryConfig` só atua quando organização, projeto e token existem no
  build, para não quebrar build local nem CI.
- `output: "standalone"` para a imagem de runtime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
camilafagundesandClaude Opus 5 committed 2026-10-15 20:38:58 -03:00
1 parent f5560cc4ac
commit d43f370359
20 files changed
+330 -47

No files matched your search

+81
View File
@@ -0,0 +1,81 @@
import "server-only";
import { AdapterError } from "@/domain/spreadsheet-adapter/errors";
import { createCoordinator } from "@/infra/redis";
import { rateLimitKey } from "@/infra/redis/redis";
import type { Coordinator } from "@/infra/redis/memory";
export type RateLimitBucket = "login" | "upload" | "process" | "download" | "mutation" | "read";
export type RateLimitRule = { limit: number; windowSeconds: number };
/**
* Tetos por janela. São limites de abuso, não de produto: o uso normal do
* piloto fica muito abaixo. `upload` e `process` são os caros — leem 10 MB
* e geram XLSX, CSV e PDF.
*/
export const RATE_LIMITS: Record<RateLimitBucket, RateLimitRule> = {
login: { limit: 10, windowSeconds: 300 },
upload: { limit: 30, windowSeconds: 300 },
process: { limit: 20, windowSeconds: 300 },
download: { limit: 120, windowSeconds: 300 },
mutation: { limit: 120, windowSeconds: 300 },
read: { limit: 600, windowSeconds: 300 },
};
export function windowStart(rule: RateLimitRule, now = Date.now()): number {
return Math.floor(now / (rule.windowSeconds * 1000));
}
/**
* Conta o acesso e devolve o estado da janela. Não lança: quem chama decide.
* Falha de coordenação não bloqueia leitura — o processamento já é recusado
* por conta própria quando o Redis está fora.
*/
export async function hitRateLimit(
bucket: RateLimitBucket,
subject: string,
options: { coordinator?: Coordinator; now?: number } = {},
): Promise<{ allowed: boolean; limit: number; remaining: number; retryAfterSeconds: number }> {
const rule = RATE_LIMITS[bucket];
const coordinator = options.coordinator ?? createCoordinator();
const now = options.now ?? Date.now();
const key = rateLimitKey(bucket, subject, windowStart(rule, now));
let count: number;
try {
count = await coordinator.hit(key, rule.windowSeconds);
} catch {
// Sem coordenação não há contagem confiável. Não inventa bloqueio.
return { allowed: true, limit: rule.limit, remaining: rule.limit, retryAfterSeconds: 0 };
}
const elapsed = now % (rule.windowSeconds * 1000);
const retryAfterSeconds = Math.max(1, Math.ceil((rule.windowSeconds * 1000 - elapsed) / 1000));
return {
allowed: count <= rule.limit,
limit: rule.limit,
remaining: Math.max(0, rule.limit - count),
retryAfterSeconds,
};
}
/** Versão que recusa a requisição. Usada pelas rotas do módulo. */
export async function enforceRateLimit(
bucket: RateLimitBucket,
subject: string,
options: { coordinator?: Coordinator; now?: number } = {},
): Promise<void> {
const result = await hitRateLimit(bucket, subject, options);
if (result.allowed) return;
throw new AdapterError(
"RATE_LIMITED",
`Muitas chamadas em sequência. Aguarde ${result.retryAfterSeconds} segundos e tente de novo.`,
429,
{ retryAfterSeconds: result.retryAfterSeconds, limit: result.limit },
);
}
/** Identificador do chamador anônimo, para a tela de login. */
export function requestSubject(request: Request): string {
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() || request.headers.get("x-real-ip") || "desconhecido";
return ip.slice(0, 64);
}