feat: add apps/api (NestJS + Fastify) with authentication endpoints

- POST /auth/login, /auth/refresh, /auth/logout, /auth/select-tenant,
  /auth/change-password, GET /auth/tenants — wired to packages/auth
- JwtAuthGuard + DomainExceptionFilter (401/403 without leaking internals)
- LoginRateLimitGuard: Redis-backed 5/min per IP and per email (agente.md
  secao 149), safe across multiple API instances
- helmet + restrictive cors (deny-by-default) + global rate limit
- GET /health, /health/live, /health/ready checking Postgres and Redis
- changePassword() added to packages/auth for the mustChangePassword flow
- fixed REDIS_HOST/POSTGRES_HOST docker-compose-only hostnames not
  resolving from the host process; added REDIS_URL for host-side use
- verified end-to-end with curl: login, wrong password / unknown email
  (same generic error), authenticated route, missing token, refresh
  rotation, logout revocation, and the 429 rate limit kicking in after 5
  attempts
This commit is contained in:
2026-08-28 06:12:34 -03:00
parent 70c5586595
commit 68b403a7ff
22 changed files with 1190 additions and 11 deletions

50
apps/api/src/main.ts Normal file
View File

@@ -0,0 +1,50 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";
import { ValidationPipe } from "@nestjs/common";
import helmet from "@fastify/helmet";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { AppModule } from "./app.module";
import { DomainExceptionFilter } from "./common/filters/domain-exception.filter";
async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>(
AppModule,
new FastifyAdapter({ trustProxy: true }),
);
// HTTP security headers (agente.md secao 182).
await app.register(helmet);
// CORS restritivo: só a origem do frontend, configurável via env. Nunca
// "*" — dados de tenant nunca devem ser acessíveis por qualquer origem.
const corsOrigin = process.env.CORS_ORIGIN;
await app.register(cors, {
origin: corsOrigin ? corsOrigin.split(",") : false,
credentials: true,
});
// Rate limit global de defesa em profundidade; o login tem um limite mais
// estrito aplicado no próprio controller (agente.md secao 149).
await app.register(rateLimit, {
max: 300,
timeWindow: "1 minute",
});
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new DomainExceptionFilter());
const port = Number(process.env.API_PORT ?? 3000);
await app.listen(port, "127.0.0.1");
console.log(`b2bcall-api ouvindo em http://127.0.0.1:${port}`);
}
bootstrap();