feat(api): foundation canon JCS — Pino+CLS+RFC9457+health+ping (Frente A)
Estabelece a fundação operacional de apps/api conforme STACK.md v2.2 e
CODING-RULES.md v2.0, substituindo o hello-world scaffolded.
- tracing.ts primeiro import (PGD-OBS-001) — stub OTel ativável por env
- EnvSchema Zod 4 com fail-fast (superRefine guarda prod) + EnvModule global
- nestjs-pino com redact LGPD (*.cpf|*.cardNumber|*.password|auth|cookie)
- main.ts hardenizado: helmet, CORS por env, compression, versionamento URI
/api/v1, graceful shutdown
- ProblemDetailsFilter global (RFC 9457 application/problem+json), Zod -> 422
- Health endpoints /api/v1/health/{live,ready} com memory.checkHeap(350MB)
(ready skeleton documenta K=3 LRU pool conforme PGD-OBS-003)
- WorkspaceModule via ClsModule.forRootAsync — requestId+workspaceId no CLS,
idGenerator alinhado com pino-http para mesmo UUID em header e body
- GET /api/v1/ping retornando workspaceId+requestId (alvo de smoke test e
futuro healthcheck do docker compose)
- ZodValidationPipe (nestjs-zod) como APP_PIPE global
- tsconfig.app.json target ES2023 (alinhado ao base)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,21 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let app: TestingModule;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
it('should return "Hello API"', () => {
|
||||
const appController = app.get<AppController>(AppController);
|
||||
expect(appController.getData()).toEqual({ message: 'Hello API' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getData() {
|
||||
return this.appService.getData();
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,28 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||
import { ZodValidationPipe } from 'nestjs-zod';
|
||||
import { EnvModule } from './config/env.module';
|
||||
import { LoggerModule } from './logger/logger.module';
|
||||
import { WorkspaceModule } from './workspace/workspace.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PingModule } from './ping/ping.module';
|
||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
imports: [
|
||||
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio.
|
||||
EnvModule,
|
||||
LoggerModule,
|
||||
WorkspaceModule,
|
||||
HealthModule,
|
||||
PingModule,
|
||||
],
|
||||
providers: [
|
||||
// Pipe global: nestjs-zod converte ZodSchema (via createZodDto) em validação automática.
|
||||
// CODING-RULES §06: schema é o contrato; DTO é a classe que o expõe.
|
||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||
// Filter global: RFC 9457. Zod → 422.
|
||||
{ provide: APP_FILTER, useClass: ProblemDetailsFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppService', () => {
|
||||
let service: AppService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = await Test.createTestingModule({
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
service = app.get<AppService>(AppService);
|
||||
});
|
||||
|
||||
describe('getData', () => {
|
||||
it('should return "Hello API"', () => {
|
||||
expect(service.getData()).toEqual({ message: 'Hello API' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getData(): { message: string } {
|
||||
return { message: 'Hello API' };
|
||||
}
|
||||
}
|
||||
21
apps/api/src/app/config/env.module.ts
Normal file
21
apps/api/src/app/config/env.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import type { Env } from './env.schema';
|
||||
import { validateEnv } from './env.schema';
|
||||
|
||||
// Tipagem do ConfigService<Env, true> garante chaves sem `string | undefined` no caller.
|
||||
export type AppConfigService = ConfigService<Env, true>;
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
cache: true,
|
||||
// CODING-RULES §08: validate com Zod — fail-fast.
|
||||
validate: (raw) => validateEnv(raw),
|
||||
}),
|
||||
],
|
||||
exports: [ConfigModule],
|
||||
})
|
||||
export class EnvModule {}
|
||||
98
apps/api/src/app/config/env.schema.ts
Normal file
98
apps/api/src/app/config/env.schema.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// CODING-RULES.md §08: validação Zod no boot (fail-fast). Sem default em prod.
|
||||
// Em dev, defaults razoáveis facilitam o setup; em prod o Vault Agent injeta tudo.
|
||||
|
||||
const NodeEnv = z.enum(['development', 'test', 'staging', 'production']);
|
||||
|
||||
export const EnvSchema = z
|
||||
.object({
|
||||
NODE_ENV: NodeEnv.default('development'),
|
||||
|
||||
// API
|
||||
API_PORT: z.coerce.number().int().positive().default(3000),
|
||||
API_HOST: z.string().min(1).default('0.0.0.0'),
|
||||
API_GLOBAL_PREFIX: z.string().min(1).default('api'),
|
||||
API_VERSION: z.string().regex(/^v\d+$/).default('v1'),
|
||||
|
||||
// CORS — origens permitidas (Web em dev: http://localhost:4200)
|
||||
CORS_ORIGINS: z
|
||||
.string()
|
||||
.default('http://localhost:4200')
|
||||
.transform((s) =>
|
||||
s
|
||||
.split(',')
|
||||
.map((o) => o.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
|
||||
// Master-login (DEV stub — IdP real virá na próxima sessão)
|
||||
MASTER_LOGIN_URL: z.url().default('http://localhost:3000/auth/dev'),
|
||||
MASTER_LOGIN_JWT_SECRET: z.string().min(32).default('dev_jwt_secret_change_in_prod_use_vault_xxxxx'),
|
||||
JWT_ACCESS_EXPIRATION: z.coerce.number().int().positive().default(900),
|
||||
JWT_REFRESH_EXPIRATION: z.coerce.number().int().positive().default(2_592_000),
|
||||
|
||||
// Multi-tenancy — workspace de dev (até master-login real entrar)
|
||||
DEFAULT_WORKSPACE_ID: z.string().min(1).default('dev-workspace'),
|
||||
|
||||
// Postgres (Prisma virá depois)
|
||||
DATABASE_URL: z.string().optional(),
|
||||
MIGRATION_DATABASE_URL: z.string().optional(),
|
||||
|
||||
// Valkey / BullMQ (entrarão com filas)
|
||||
REDIS_URL: z.url().default('redis://localhost:6379'),
|
||||
|
||||
// MinIO (entrará com uploads)
|
||||
S3_ENDPOINT: z.url().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_SECRET_KEY: z.string().optional(),
|
||||
S3_BUCKET: z.string().optional(),
|
||||
|
||||
// SMTP (Mailpit dev / Resend prod)
|
||||
SMTP_HOST: z.string().default('localhost'),
|
||||
SMTP_PORT: z.coerce.number().int().positive().default(1025),
|
||||
SMTP_FROM: z.email().default('noreply@sar.dev'),
|
||||
|
||||
// Telemetry
|
||||
OTEL_SERVICE_NAME: z.string().default('sar-api'),
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: z.url().optional(),
|
||||
OTEL_TRACES_SAMPLER: z.string().default('parentbased_traceidratio'),
|
||||
OTEL_TRACES_SAMPLER_ARG: z.coerce.number().min(0).max(1).default(1.0),
|
||||
SENTRY_DSN: z.string().optional(),
|
||||
|
||||
// Feature flags
|
||||
GROWTHBOOK_API_HOST: z.string().optional(),
|
||||
GROWTHBOOK_CLIENT_KEY: z.string().optional(),
|
||||
|
||||
// Logger
|
||||
LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.NODE_ENV === 'production') {
|
||||
if (env.MASTER_LOGIN_JWT_SECRET.startsWith('dev_jwt_secret')) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['MASTER_LOGIN_JWT_SECRET'],
|
||||
message: 'JWT secret de DEV não pode ser usada em produção (CODING-RULES §08, PGD-SEC-002).',
|
||||
});
|
||||
}
|
||||
if (!env.DATABASE_URL) {
|
||||
ctx.addIssue({ code: 'custom', path: ['DATABASE_URL'], message: 'obrigatório em produção' });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
export function validateEnv(raw: Record<string, unknown>): Env {
|
||||
const result = EnvSchema.safeParse(raw);
|
||||
if (!result.success) {
|
||||
// Fail-fast: agrupa erros legíveis antes do crash.
|
||||
const issues = result.error.issues
|
||||
.map((i) => ` - ${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`[env] validação falhou — corrija .env:\n${issues}`);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
170
apps/api/src/app/filters/problem-details.filter.ts
Normal file
170
apps/api/src/app/filters/problem-details.filter.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
// RFC 9457 Problem Details for HTTP APIs.
|
||||
// CODING-RULES §05: Zod sempre 422 (nunca 400). 403 vira 404 onde o cliente
|
||||
// não pode saber da existência (regra aplicada por guards de domínio — aqui
|
||||
// só repassamos o status que o caller escolheu).
|
||||
|
||||
const PROBLEM_CONTENT_TYPE = 'application/problem+json';
|
||||
const BASE_TYPE_URI = 'https://docs.sar.jcs.com.br/errors';
|
||||
|
||||
interface ProblemDetails {
|
||||
type: string;
|
||||
title: string;
|
||||
status: number;
|
||||
detail?: string;
|
||||
instance: string;
|
||||
requestId?: string;
|
||||
errors?: ReadonlyArray<{ path: string; message: string; code?: string }>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@Catch()
|
||||
export class ProblemDetailsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(ProblemDetailsFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const req = ctx.getRequest<Request>();
|
||||
const res = ctx.getResponse<Response>();
|
||||
|
||||
const problem = this.toProblem(exception, req);
|
||||
|
||||
if (problem.status >= 500) {
|
||||
this.logger.error(
|
||||
{ err: exception, requestId: problem.requestId, path: problem.instance },
|
||||
problem.title,
|
||||
);
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', PROBLEM_CONTENT_TYPE);
|
||||
res.status(problem.status).json(problem);
|
||||
}
|
||||
|
||||
private toProblem(exception: unknown, req: Request): ProblemDetails {
|
||||
const requestId = this.extractRequestId(req);
|
||||
const instance = req.originalUrl ?? req.url ?? '/';
|
||||
|
||||
// Zod direto (validação manual ou rethrow de pipe sem nestjs-zod).
|
||||
if (exception instanceof ZodError) {
|
||||
return this.zodProblem(exception, instance, requestId);
|
||||
}
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
return this.httpProblem(exception, instance, requestId);
|
||||
}
|
||||
|
||||
return {
|
||||
type: `${BASE_TYPE_URI}/internal-server-error`,
|
||||
title: 'Internal Server Error',
|
||||
status: HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
detail: 'Erro interno. Reporte ao suporte com o requestId.',
|
||||
instance,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
private zodProblem(err: ZodError, instance: string, requestId?: string): ProblemDetails {
|
||||
return {
|
||||
type: `${BASE_TYPE_URI}/validation`,
|
||||
title: 'Unprocessable Entity',
|
||||
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
detail: 'Validação dos dados de entrada falhou.',
|
||||
instance,
|
||||
requestId,
|
||||
errors: err.issues.map((i) => ({
|
||||
path: i.path.join('.'),
|
||||
message: i.message,
|
||||
code: i.code,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private httpProblem(err: HttpException, instance: string, requestId?: string): ProblemDetails {
|
||||
const status = err.getStatus();
|
||||
const response = err.getResponse();
|
||||
|
||||
// nestjs-zod lança ZodValidationException (HttpException 400/422) com payload já estruturado.
|
||||
if (typeof response === 'object' && response !== null) {
|
||||
const r = response as Record<string, unknown>;
|
||||
// nestjs-zod 5.x: { statusCode, message, errors: ZodIssue[] }
|
||||
if (Array.isArray(r['errors']) && r['errors'].length > 0) {
|
||||
const issues = r['errors'] as Array<{ path?: unknown; message?: unknown; code?: unknown }>;
|
||||
return {
|
||||
type: `${BASE_TYPE_URI}/validation`,
|
||||
title: 'Unprocessable Entity',
|
||||
status: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
detail: typeof r['message'] === 'string' ? (r['message'] as string) : 'Validação falhou.',
|
||||
instance,
|
||||
requestId,
|
||||
errors: issues.map((i) => ({
|
||||
path: Array.isArray(i.path) ? i.path.join('.') : String(i.path ?? ''),
|
||||
message: String(i.message ?? ''),
|
||||
code: typeof i.code === 'string' ? i.code : undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const title = this.titleFor(status);
|
||||
return {
|
||||
type: `${BASE_TYPE_URI}/${this.slug(title)}`,
|
||||
title,
|
||||
status,
|
||||
detail: typeof r['message'] === 'string' ? (r['message'] as string) : title,
|
||||
instance,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
const title = this.titleFor(status);
|
||||
return {
|
||||
type: `${BASE_TYPE_URI}/${this.slug(title)}`,
|
||||
title,
|
||||
status,
|
||||
detail: typeof response === 'string' ? response : title,
|
||||
instance,
|
||||
requestId,
|
||||
};
|
||||
}
|
||||
|
||||
private extractRequestId(req: Request): string | undefined {
|
||||
const fromHeader = req.headers['x-request-id'];
|
||||
if (typeof fromHeader === 'string') return fromHeader;
|
||||
const reqId = (req as Request & { id?: unknown }).id;
|
||||
return typeof reqId === 'string' ? reqId : undefined;
|
||||
}
|
||||
|
||||
private titleFor(status: number): string {
|
||||
switch (status) {
|
||||
case 400:
|
||||
return 'Bad Request';
|
||||
case 401:
|
||||
return 'Unauthorized';
|
||||
case 403:
|
||||
return 'Forbidden';
|
||||
case 404:
|
||||
return 'Not Found';
|
||||
case 409:
|
||||
return 'Conflict';
|
||||
case 422:
|
||||
return 'Unprocessable Entity';
|
||||
case 429:
|
||||
return 'Too Many Requests';
|
||||
default:
|
||||
return status >= 500 ? 'Internal Server Error' : 'Error';
|
||||
}
|
||||
}
|
||||
|
||||
private slug(title: string): string {
|
||||
return title.toLowerCase().replace(/\s+/g, '-');
|
||||
}
|
||||
}
|
||||
42
apps/api/src/app/health/health.controller.ts
Normal file
42
apps/api/src/app/health/health.controller.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import {
|
||||
HealthCheck,
|
||||
HealthCheckResult,
|
||||
HealthCheckService,
|
||||
MemoryHealthIndicator,
|
||||
} from '@nestjs/terminus';
|
||||
|
||||
// CODING-RULES §20 (PGD-OBS-003):
|
||||
// /health/live → liveness só com memory.checkHeap(350MB).
|
||||
// /health/ready → readiness pinga master-login + amostra LRU (K=3) dos pools
|
||||
// quentes do WorkspacePrismaPool + Valkey + BullMQ.
|
||||
// NUNCA percorrer todos os workspaces (O(N) → false negative).
|
||||
//
|
||||
// Hoje o "ready" só checa heap, idêntico ao live. Quando master-login,
|
||||
// WorkspacePrismaPool, Valkey e BullMQ entrarem, cada um adiciona seu indicator
|
||||
// aqui — sem nunca virar O(N) sobre workspaces.
|
||||
|
||||
@Controller({ path: 'health' })
|
||||
export class HealthController {
|
||||
constructor(
|
||||
private readonly health: HealthCheckService,
|
||||
private readonly memory: MemoryHealthIndicator,
|
||||
) {}
|
||||
|
||||
@Get('live')
|
||||
@HealthCheck()
|
||||
live(): Promise<HealthCheckResult> {
|
||||
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
@HealthCheck()
|
||||
ready(): Promise<HealthCheckResult> {
|
||||
// Skeleton: por enquanto idêntico ao live. Próximas frentes:
|
||||
// - MasterLoginHealthIndicator (obrigatório)
|
||||
// - WorkspacePoolLruHealthIndicator (K=3 amostra)
|
||||
// - ValkeyHealthIndicator
|
||||
// - BullMQHealthIndicator
|
||||
return this.health.check([() => this.memory.checkHeap('heap', 350 * 1024 * 1024)]);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/health/health.module.ts
Normal file
9
apps/api/src/app/health/health.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TerminusModule } from '@nestjs/terminus';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TerminusModule],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
81
apps/api/src/app/logger/logger.module.ts
Normal file
81
apps/api/src/app/logger/logger.module.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LoggerModule as PinoLoggerModule } from 'nestjs-pino';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Env } from '../config/env.schema';
|
||||
|
||||
// CODING-RULES §09 (PGD-OBS-002): redact LGPD agressivo.
|
||||
// Sempre cobrir: *.cpf, *.cardNumber, *.password, req.headers.authorization, req.headers.cookie.
|
||||
const REDACT_PATHS = [
|
||||
'*.cpf',
|
||||
'*.cardNumber',
|
||||
'*.password',
|
||||
'*.passwordHash',
|
||||
'*.token',
|
||||
'*.secret',
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'req.headers["set-cookie"]',
|
||||
'res.headers["set-cookie"]',
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
PinoLoggerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Env, true>) => {
|
||||
const isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||
const level = config.get('LOG_LEVEL', { infer: true });
|
||||
const serviceName = config.get('OTEL_SERVICE_NAME', { infer: true });
|
||||
|
||||
return {
|
||||
pinoHttp: {
|
||||
level,
|
||||
// pretty em dev; JSON estruturado em prod (Grafana LGTM consumirá).
|
||||
transport: isProd
|
||||
? undefined
|
||||
: {
|
||||
target: 'pino-pretty',
|
||||
options: {
|
||||
colorize: true,
|
||||
translateTime: 'SYS:HH:MM:ss.l',
|
||||
ignore: 'pid,hostname,req,res,responseTime',
|
||||
messageFormat: '{msg} {req.method} {req.url} {res.statusCode} ({responseTime}ms)',
|
||||
},
|
||||
},
|
||||
redact: {
|
||||
paths: REDACT_PATHS,
|
||||
censor: '[REDACTED]',
|
||||
},
|
||||
// Correlation ID: respeita header upstream ou gera UUID v4.
|
||||
genReqId: (req: IncomingMessage, res: ServerResponse) => {
|
||||
const headerVal =
|
||||
req.headers['x-request-id'] ??
|
||||
req.headers['x-correlation-id'] ??
|
||||
req.headers['traceparent'];
|
||||
const id = typeof headerVal === 'string' && headerVal.length > 0 ? headerVal : randomUUID();
|
||||
res.setHeader('x-request-id', id);
|
||||
return id;
|
||||
},
|
||||
customProps: () => ({ service: serviceName }),
|
||||
// Reduz ruído em health checks.
|
||||
customLogLevel: (_req, res, err) => {
|
||||
if (err || res.statusCode >= 500) return 'error';
|
||||
if (res.statusCode >= 400) return 'warn';
|
||||
return 'info';
|
||||
},
|
||||
autoLogging: {
|
||||
ignore: (req) => {
|
||||
const url = req.url ?? '';
|
||||
return url.startsWith('/api/v1/health');
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
exports: [PinoLoggerModule],
|
||||
})
|
||||
export class LoggerModule {}
|
||||
36
apps/api/src/app/ping/ping.controller.ts
Normal file
36
apps/api/src/app/ping/ping.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
// Endpoint de verificação de fundação:
|
||||
// - confirma que CLS está populando workspaceId + requestId;
|
||||
// - serve como alvo do healthcheck do docker compose / smoke test;
|
||||
// - usado pela Web (Frente B) para validar conectividade real.
|
||||
|
||||
interface PingResponse {
|
||||
status: 'ok';
|
||||
service: string;
|
||||
version: string;
|
||||
workspaceId: string;
|
||||
requestId: string;
|
||||
uptimeSeconds: number;
|
||||
now: string;
|
||||
}
|
||||
|
||||
@Controller({ path: 'ping' })
|
||||
export class PingController {
|
||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||
|
||||
@Get()
|
||||
ping(): PingResponse {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'sar-api',
|
||||
version: process.env['npm_package_version'] ?? '0.1.0',
|
||||
workspaceId: this.cls.get('workspaceId'),
|
||||
requestId: this.cls.get('requestId'),
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
now: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/api/src/app/ping/ping.module.ts
Normal file
7
apps/api/src/app/ping/ping.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PingController } from './ping.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [PingController],
|
||||
})
|
||||
export class PingModule {}
|
||||
48
apps/api/src/app/workspace/workspace.module.ts
Normal file
48
apps/api/src/app/workspace/workspace.module.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ClsModule } from 'nestjs-cls';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { WorkspaceClsStore } from './workspace.types';
|
||||
import type { Env } from '../config/env.schema';
|
||||
|
||||
// CLS popula contexto por request. Hoje: requestId + DEFAULT_WORKSPACE_ID do env.
|
||||
// Amanhã: workspaceId vem do JWT (PGD-AUTHZ-002); `prisma` é resolvido pelo
|
||||
// WorkspacePrismaPool e injetado via cls.set('prisma', ...) aqui mesmo.
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ClsModule.forRootAsync({
|
||||
global: true,
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Env, true>) => ({
|
||||
middleware: {
|
||||
mount: true,
|
||||
generateId: true,
|
||||
idGenerator: (req: Request) => {
|
||||
// Prioridade: req.id (pino-http já gerou/leu header) > header bruto > novo UUID.
|
||||
const fromPino = (req as Request & { id?: unknown }).id;
|
||||
if (typeof fromPino === 'string' && fromPino.length > 0) return fromPino;
|
||||
const headerVal = req.headers['x-request-id'];
|
||||
return typeof headerVal === 'string' && headerVal.length > 0
|
||||
? headerVal
|
||||
: randomUUID();
|
||||
},
|
||||
setup: (cls, req: Request, res: Response) => {
|
||||
const store = cls as unknown as {
|
||||
set: <K extends keyof WorkspaceClsStore>(key: K, value: WorkspaceClsStore[K]) => void;
|
||||
getId: () => string;
|
||||
};
|
||||
const requestId = store.getId();
|
||||
res.setHeader('x-request-id', requestId);
|
||||
store.set('requestId', requestId);
|
||||
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
exports: [ClsModule],
|
||||
})
|
||||
export class WorkspaceModule {}
|
||||
13
apps/api/src/app/workspace/workspace.types.ts
Normal file
13
apps/api/src/app/workspace/workspace.types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ClsStore } from 'nestjs-cls';
|
||||
|
||||
// Forma do CLS store por request — fonte da verdade para qualquer caller
|
||||
// que faça `cls.get(...)`. Quando o PrismaClient por workspace entrar
|
||||
// (ADR 0006), `prisma` virará obrigatório aqui — por hora segue opcional.
|
||||
|
||||
export interface WorkspaceClsStore extends ClsStore {
|
||||
requestId: string;
|
||||
workspaceId: string;
|
||||
// userId virá quando master-login estiver plugado.
|
||||
userId?: string;
|
||||
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
|
||||
}
|
||||
@@ -1,19 +1,82 @@
|
||||
/**
|
||||
* This is not a production server yet!
|
||||
* This is only a minimal backend to get started.
|
||||
*/
|
||||
// CODING-RULES §09 (PGD-OBS-001): tracing PRECISA ser o primeiro import.
|
||||
// OTel NodeSDK faz monkey-patch dos módulos node:http/pg/etc. antes do Nest.
|
||||
import './tracing';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { VersioningType, type INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import { AppModule } from './app/app.module';
|
||||
import type { Env } from './app/config/env.schema';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const globalPrefix = 'api';
|
||||
async function bootstrap(): Promise<void> {
|
||||
const app: INestApplication = await NestFactory.create(AppModule, {
|
||||
bufferLogs: true, // logs do boot vão pro Pino quando ele subir
|
||||
});
|
||||
|
||||
// Logger Pino global (substitui o NestLogger padrão).
|
||||
app.useLogger(app.get(Logger));
|
||||
|
||||
const config = app.get(ConfigService) as ConfigService<Env, true>;
|
||||
|
||||
// CORS — origens vindas do EnvSchema (já parseadas em array).
|
||||
app.enableCors({
|
||||
origin: config.get('CORS_ORIGINS', { infer: true }),
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||
allowedHeaders: [
|
||||
'Content-Type',
|
||||
'Authorization',
|
||||
'X-Request-Id',
|
||||
'X-Correlation-Id',
|
||||
'Idempotency-Key',
|
||||
],
|
||||
exposedHeaders: ['X-Request-Id'],
|
||||
maxAge: 600,
|
||||
});
|
||||
|
||||
// Helmet — headers de segurança canônicos.
|
||||
app.use(
|
||||
helmet({
|
||||
contentSecurityPolicy: false, // SPA serve CSP via Nginx; API JSON dispensa.
|
||||
crossOriginEmbedderPolicy: false,
|
||||
}),
|
||||
);
|
||||
|
||||
// Compression — gzip/br.
|
||||
app.use(compression());
|
||||
|
||||
// Versionamento via URI: /api/v1/...
|
||||
const globalPrefix = config.get('API_GLOBAL_PREFIX', { infer: true });
|
||||
const apiVersion = config.get('API_VERSION', { infer: true }).replace(/^v/, '');
|
||||
app.setGlobalPrefix(globalPrefix);
|
||||
const port = process.env.PORT || 3000;
|
||||
await app.listen(port);
|
||||
Logger.log(`🚀 Application is running on: http://localhost:${port}/${globalPrefix}`);
|
||||
app.enableVersioning({
|
||||
type: VersioningType.URI,
|
||||
defaultVersion: apiVersion,
|
||||
prefix: 'v',
|
||||
});
|
||||
|
||||
// Graceful shutdown — Nest emite SIGTERM/SIGINT → hooks rodam → dreno de conexões.
|
||||
// Compose: stop_grace_period: 30s + HAProxy drain 30s (CODING-RULES §18).
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const port = config.get('API_PORT', { infer: true });
|
||||
const host = config.get('API_HOST', { infer: true });
|
||||
|
||||
await app.listen(port, host);
|
||||
|
||||
app
|
||||
.get(Logger)
|
||||
.log(
|
||||
`SAR API pronta em http://${host}:${port}/${globalPrefix}/${apiVersion === '0' ? '' : `v${apiVersion}`}`,
|
||||
'Bootstrap',
|
||||
);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
bootstrap().catch((err) => {
|
||||
// Antes do Logger Pino estar disponível, qualquer erro de boot cai aqui.
|
||||
console.error('[bootstrap] crash fatal — encerrando:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
22
apps/api/src/tracing.ts
Normal file
22
apps/api/src/tracing.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// CODING-RULES.md §09 (PGD-OBS-001):
|
||||
// Este arquivo DEVE ser o primeiro import de main.ts — antes do NestJS.
|
||||
// OTel NodeSDK precisa fazer monkey-patch dos módulos node:http, pg, etc.
|
||||
// antes que qualquer outro código os carregue.
|
||||
//
|
||||
// Stub ativo:
|
||||
// - Quando OTEL_EXPORTER_OTLP_ENDPOINT estiver definida, inicializa SDK real
|
||||
// (a ser plugado quando @opentelemetry/sdk-node entrar no canon catalog).
|
||||
// - Caso contrário, no-op silencioso (dev local).
|
||||
//
|
||||
// Sampling alvo (prod): parentbased_traceidratio com ARG=0.1 (10% head-based).
|
||||
|
||||
const otlpEndpoint = process.env['OTEL_EXPORTER_OTLP_ENDPOINT'];
|
||||
|
||||
if (otlpEndpoint) {
|
||||
// TODO(otel): plugar @opentelemetry/sdk-node + auto-instrumentations
|
||||
// quando dependência entrar no catálogo. Stub atual mantém posição correta
|
||||
// do import sem instalar dependência pesada em solo-founder mode.
|
||||
console.warn(
|
||||
'[tracing] OTEL_EXPORTER_OTLP_ENDPOINT definido mas SDK não plugado ainda — placeholder ativo.',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user