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:
1 parent
f5560cc4ac
commit
d43f370359
20 files changed
+330
-47
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user