feat(api): master-login stub + WorkspacePrismaPool (Frente E)

- Prisma 7: prisma.config.ts com datasource.url (API correta); schema gerado em CJS
- WorkspacePrismaPool: LRU cache (max 10) de PrismaClient por workspace (ADR 0006)
  PrismaPg adapter + pg.Pool por workspace; getOrCreate/health/onModuleDestroy
- JwtAuthGuard: global APP_GUARD, jose HS256, popula CLS com workspace_id/userId/prisma
  @Public() decorator marca ping/health/dev-auth como rotas abertas
- DevAuthController: POST /auth/dev/token — emite JWT dev (404 em produção)
- AuthTokenResponseSchema + DevTokenRequestSchema em @sar/api-interface
- WorkspacePoolHealthIndicator: health/ready reporta amostra LRU top-3 (nunca O(N))
- .npmrc: hoist @prisma/client-runtime-utils (requerido pelo Prisma 7 isolated mode)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 22:36:00 +00:00
parent bca2e3ebb3
commit 2a8be3fd82
22 changed files with 1204 additions and 39 deletions

17
apps/api/prisma.config.ts Normal file
View File

@@ -0,0 +1,17 @@
// Prisma 7 config — usado pelo CLI (migrate, generate, studio).
// Conexão de runtime fica no WorkspacePrismaPool (adapter por workspace).
// CODING-RULES PGD-DB-001: DATABASE_URL aponta direto ao PG na porta 5432 (sem PgBouncer).
import path from 'node:path';
import { defineConfig } from 'prisma/config';
export default defineConfig({
schema: path.join(import.meta.dirname, 'prisma/schema.prisma'),
datasource: {
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
url:
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
},
});

View File

@@ -0,0 +1,26 @@
// SAR — Workspace Database Schema
// Stack canon: Prisma 7 · PostgreSQL 18 · BD-por-workspace (ADR 0006)
//
// Este schema roda em CADA workspace DB (sar_workspace_<id>).
// NÃO há workspaceId/tenantId em nenhum modelo — o isolamento é físico.
// O banco master (sar_master) é gerenciado pelo master-login (IdP JCS), não por este schema.
//
// CODING-RULES PGD-DB-004: moduleFormat = "cjs" (NestJS é CJS)
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
generator client {
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
}
// Prisma 7: url foi removida do schema — conexão fica em prisma.config.ts (migrate)
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
datasource db {
provider = "postgresql"
}
// ─── Modelos de domínio serão adicionados por feature ──────────────────────
//
// Próximos: Client (C2), Order + OrderItem (C3/C4) — vindos das stories.
// Cada model novo exige: migration versionada + seed de dev atualizado.

View File

@@ -1,28 +1,30 @@
import { Module } from '@nestjs/common';
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
import { APP_FILTER, APP_GUARD, 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 { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
imports: [
// Ordem importa: Env primeiro (fail-fast), depois Logger, CLS, módulos de domínio.
// Ordem: Env primeiro (fail-fast), depois Logger, CLS, Auth, módulos de domínio.
EnvModule,
LoggerModule,
WorkspaceModule,
AuthModule,
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 },
// Guard global — exige JWT em todas as rotas exceto as com @Public().
{ provide: APP_GUARD, useClass: JwtAuthGuard },
],
})
export class AppModule {}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { WorkspaceModule } from '../workspace/workspace.module';
import { JwtAuthGuard } from './jwt-auth.guard';
import { DevAuthController } from './dev-auth.controller';
@Module({
imports: [WorkspaceModule],
controllers: [DevAuthController],
providers: [JwtAuthGuard],
exports: [JwtAuthGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,46 @@
import { Body, Controller, HttpCode, HttpStatus, NotFoundException, Post } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SignJWT } from 'jose';
import { createZodDto } from 'nestjs-zod';
import { DevTokenRequestSchema, type AuthTokenResponse } from '@sar/api-interface';
import type { Env } from '../config/env.schema';
import { Public } from './public.decorator';
class DevTokenRequestDto extends createZodDto(DevTokenRequestSchema) {}
// Dev-only stub — emite JWT HS256 para smoke tests locais.
// CODING-RULES PGD-SEC-002: retorna 404 em produção.
// CODING-RULES PGD-AUTHZ-002: workspace_id vem do body aqui APENAS porque
// este endpoint É o gerador do token — nenhum outro handler pode fazer isso.
@Public()
@Controller({ path: 'auth/dev' })
export class DevAuthController {
private readonly secret: Uint8Array;
private readonly expiresIn: number;
private readonly isProd: boolean;
constructor(config: ConfigService<Env, true>) {
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
}
@Post('token')
@HttpCode(HttpStatus.OK)
async token(@Body() dto: DevTokenRequestDto): Promise<AuthTokenResponse> {
if (this.isProd) throw new NotFoundException();
const accessToken = await new SignJWT({
workspace_id: dto.workspaceId,
role: dto.role,
})
.setProtectedHeader({ alg: 'HS256' })
.setSubject(dto.userId)
.setIssuedAt()
.setExpirationTime(`${this.expiresIn}s`)
.sign(this.secret);
return { accessToken, tokenType: 'Bearer', expiresIn: this.expiresIn };
}
}

View File

@@ -0,0 +1,79 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { ClsService } from 'nestjs-cls';
import { jwtVerify } from 'jose';
import type { Request } from 'express';
import type { Env } from '../config/env.schema';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
import type { JwtPayload } from './jwt.types';
import { IS_PUBLIC_KEY } from './public.decorator';
// Guard global (APP_GUARD). Valida Bearer HS256 e atualiza CLS com workspace real.
// CODING-RULES PGD-AUTHZ-002: workspaceId sempre do JWT, nunca de body/param.
// Ordem NestJS: middleware CLS (workspace default) → este guard (workspace real).
@Injectable()
export class JwtAuthGuard implements CanActivate {
private readonly secret: Uint8Array;
constructor(
private readonly reflector: Reflector,
private readonly cls: ClsService<WorkspaceClsStore>,
private readonly pool: WorkspacePrismaPool,
config: ConfigService<Env, true>,
) {
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
}
async canActivate(context: ExecutionContext): Promise<boolean> {
if (this.isPublic(context)) return true;
const req = context.switchToHttp().getRequest<Request>();
const token = this.extractBearer(req);
if (!token) {
throw new UnauthorizedException('token ausente');
}
try {
const { payload } = await jwtVerify<JwtPayload>(token, this.secret, {
algorithms: ['HS256'],
});
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
// Sobrescreve CLS com workspace real do JWT (corre depois do middleware).
const workspaceId = payload.workspace_id;
this.cls.set('workspaceId', workspaceId);
this.cls.set('userId', payload.sub);
const dbUrl =
process.env['DATABASE_URL'] ??
`postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_${workspaceId}`;
this.cls.set('prisma', this.pool.getOrCreate(workspaceId, dbUrl));
return true;
} catch {
throw new UnauthorizedException('token inválido ou expirado');
}
}
private isPublic(ctx: ExecutionContext): boolean {
return (
this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
ctx.getHandler(),
ctx.getClass(),
]) === true
);
}
private extractBearer(req: Request): string | undefined {
const auth = req.headers['authorization'];
if (typeof auth === 'string' && auth.startsWith('Bearer ')) {
return auth.slice(7);
}
return undefined;
}
}

View File

@@ -0,0 +1,17 @@
// Claims do JWT emitido pelo master-login. Fonte da verdade para req.user.
// CODING-RULES PGD-AUTHZ-002: workspace_id vem sempre do token, nunca do body.
export type JwtRole = 'rep' | 'supervisor' | 'manager' | 'admin';
export interface JwtPayload {
sub: string; // userId
workspace_id: string;
role: JwtRole;
iat?: number;
exp?: number;
}
// Tipo auxiliar para requests autenticados — evita global namespace augmentation.
export interface AuthenticatedRequest {
user: JwtPayload;
}

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
/** Marca um controller ou handler como público — JwtAuthGuard não exige token. */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);

View File

@@ -5,22 +5,22 @@ import {
HealthCheckService,
MemoryHealthIndicator,
} from '@nestjs/terminus';
import { Public } from '../auth/public.decorator';
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
// 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.
// /health/live → liveness: memory.checkHeap(350MB).
// /health/ready → readiness: heap + amostra LRU (K=3) do WorkspacePrismaPool.
// Próximos: MasterLoginHealthIndicator, ValkeyHealthIndicator, BullMQHealthIndicator.
// NUNCA percorrer todos os workspaces (O(N)).
@Public()
@Controller({ path: 'health' })
export class HealthController {
constructor(
private readonly health: HealthCheckService,
private readonly memory: MemoryHealthIndicator,
private readonly workspacePool: WorkspacePoolHealthIndicator,
) {}
@Get('live')
@@ -32,11 +32,9 @@ export class HealthController {
@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)]);
return this.health.check([
() => this.memory.checkHeap('heap', 350 * 1024 * 1024),
() => this.workspacePool.check('workspace_pool', 3),
]);
}
}

View File

@@ -1,9 +1,12 @@
import { Module } from '@nestjs/common';
import { TerminusModule } from '@nestjs/terminus';
import { WorkspaceModule } from '../workspace/workspace.module';
import { HealthController } from './health.controller';
import { WorkspacePoolHealthIndicator } from './workspace-pool.health-indicator';
@Module({
imports: [TerminusModule],
imports: [TerminusModule, WorkspaceModule],
controllers: [HealthController],
providers: [WorkspacePoolHealthIndicator],
})
export class HealthModule {}

View File

@@ -0,0 +1,35 @@
import { Injectable } from '@nestjs/common';
import { HealthIndicatorResult, HealthIndicator, HealthCheckError } from '@nestjs/terminus';
import { WorkspacePrismaPool } from '../workspace/workspace-prisma-pool.service';
// Amostra os K workspaces mais quentes do LRU — nunca O(N) sobre todos os workspaces.
// CODING-RULES §20 (PGD-OBS-003): readiness não percorre workspaces individualmente.
@Injectable()
export class WorkspacePoolHealthIndicator extends HealthIndicator {
constructor(private readonly pool: WorkspacePrismaPool) {
super();
}
async check(key = 'workspace_pool', k = 3): Promise<HealthIndicatorResult> {
const results = await this.pool.health(k);
if (results.length === 0) {
return this.getStatus(key, true, { active: 0 });
}
const failed = results.filter((r) => !r.ok);
const isHealthy = failed.length === 0;
const detail = {
active: results.length,
healthy: results.length - failed.length,
...(failed.length > 0 && { failed: failed.map((r) => r.workspaceId) }),
};
if (!isHealthy) {
throw new HealthCheckError(`${key} degradado`, this.getStatus(key, false, detail));
}
return this.getStatus(key, true, detail);
}
}

View File

@@ -2,6 +2,7 @@ import { Controller, Get } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type { PingResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { Public } from '../auth/public.decorator';
// Endpoint de verificação de fundação:
// - confirma que CLS está populando workspaceId + requestId;
@@ -9,6 +10,7 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
// - usado pela Web (Frente B) para validar conectividade real.
// Contrato: @sar/api-interface · PingResponseSchema (zod).
@Public()
@Controller({ path: 'ping' })
export class PingController {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}

View File

@@ -0,0 +1,80 @@
import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
// ADR 0006: BD-por-workspace — um PrismaClient por workspaceId, nunca singleton.
// CODING-RULES PGD-DB-009: callers obtêm o client via CLS, não injetando este serviço.
const MAX_ENTRIES = 10; // LRU cap; ajustável via env na próxima iteração
const PG_POOL_SIZE = 5;
const noop = (): void => undefined;
interface PoolEntry {
client: PrismaClient;
pgPool: pg.Pool;
}
@Injectable()
export class WorkspacePrismaPool implements OnModuleDestroy {
private readonly logger = new Logger(WorkspacePrismaPool.name);
// Map preserves insertion order → LRU: primeiro = mais antigo, último = mais recente
private readonly cache = new Map<string, PoolEntry>();
getOrCreate(workspaceId: string, dbUrl: string): PrismaClient {
const hit = this.cache.get(workspaceId);
if (hit) {
// Move para o fim (LRU refresh)
this.cache.delete(workspaceId);
this.cache.set(workspaceId, hit);
return hit.client;
}
if (this.cache.size >= MAX_ENTRIES) {
this.evictOldest();
}
const pgPool = new pg.Pool({ connectionString: dbUrl, max: PG_POOL_SIZE });
const adapter = new PrismaPg(pgPool);
const client = new PrismaClient({ adapter });
this.cache.set(workspaceId, { client, pgPool });
this.logger.log(`pool criado: workspace=${workspaceId} total=${this.cache.size}`);
return client;
}
async health(k = 3): Promise<{ workspaceId: string; ok: boolean; latencyMs?: number }[]> {
// Verifica os k workspaces mais recentes
const entries = [...this.cache.entries()].slice(-k);
return Promise.all(
entries.map(async ([workspaceId, { pgPool }]) => {
const start = Date.now();
try {
const conn = await pgPool.connect();
conn.release();
return { workspaceId, ok: true, latencyMs: Date.now() - start };
} catch {
return { workspaceId, ok: false };
}
}),
);
}
async onModuleDestroy(): Promise<void> {
await Promise.allSettled(
[...this.cache.values()].map(({ client, pgPool }) =>
client.$disconnect().finally(() => pgPool.end()),
),
);
this.cache.clear();
this.logger.log('pool destruído — todos os clientes desconectados');
}
private evictOldest(): void {
const [oldestId, oldest] = this.cache.entries().next().value as [string, PoolEntry];
void oldest.client.$disconnect().catch(noop);
void oldest.pgPool.end().catch(noop);
this.cache.delete(oldestId);
this.logger.log(`evicted LRU workspace=${oldestId}`);
}
}

View File

@@ -5,10 +5,13 @@ import { randomUUID } from 'node:crypto';
import type { Request, Response } from 'express';
import type { WorkspaceClsStore } from './workspace.types';
import type { Env } from '../config/env.schema';
import { WorkspacePrismaPool } from './workspace-prisma-pool.service';
// 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.
// CLS middleware roda ANTES dos guards (ordem NestJS).
// Aqui: apenas requestId + workspaceId default.
// JwtAuthGuard atualiza workspaceId, userId e prisma após validar o token.
// CODING-RULES PGD-DB-009: prisma via cls.get('prisma'), nunca singleton.
// CODING-RULES PGD-AUTHZ-002: workspaceId real vem do JWT (guard), não do env.
@Module({
imports: [
@@ -21,28 +24,28 @@ import type { Env } from '../config/env.schema';
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();
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);
// Fallback para rotas públicas (ping, health). Guard sobrescreve em rotas protegidas.
store.set('workspaceId', config.get('DEFAULT_WORKSPACE_ID', { infer: true }));
},
},
}),
}),
],
exports: [ClsModule],
providers: [WorkspacePrismaPool],
exports: [ClsModule, WorkspacePrismaPool],
})
export class WorkspaceModule {}

View File

@@ -1,13 +1,13 @@
import type { ClsStore } from 'nestjs-cls';
import type { PrismaClient } from '@prisma/client';
// 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.
// Forma do CLS store por request — fonte da verdade para qualquer caller.
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
// CODING-RULES PGD-AUTHZ-002: workspaceId vem sempre do JWT, nunca de body/param/query.
export interface WorkspaceClsStore extends ClsStore {
requestId: string;
workspaceId: string;
// userId virá quando master-login estiver plugado.
userId?: string;
// prisma: PrismaClient — adicionar quando WorkspacePrismaPool entrar.
userId?: string; // preenchido pelo JwtAuthGuard (M3)
prisma?: PrismaClient; // preenchido pelo WorkspaceModule após WorkspacePrismaPool entrar (M5)
}