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

1
.npmrc
View File

@@ -6,4 +6,5 @@ shamefully-hoist=false
public-hoist-pattern[]=*types*
public-hoist-pattern[]=*eslint*
public-hoist-pattern[]=*prettier*
public-hoist-pattern[]=@prisma/client-runtime-utils
node-linker=isolated

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)
}

View File

@@ -384,9 +384,35 @@
- **OQs abertas (6):** OQ-1/OQ-4 são phase-blockers para C2/C4 — dependem do primeiro cliente. OQ-3/OQ-6 são non-blockers.
- **Reviewer gate:** 1 revisor subagente — veredito "Aprovado com Ressalvas"; 3 achados incorporados antes do `final`.
- **Pendente próxima sessão (ordem atualizada):**
1. **Master-login stub + WorkspacePrismaPool** — frente arquitetural pesada. PRD está pronto; pode iniciar modelagem de domínio.
2. **OpenTelemetry SDK** plugar quando entrar no catálogo.
3. **Design C2/C4** (Consulta de Clientes + Lançamento de Pedido) — após resolver OQ-1 e OQ-4 com o primeiro cliente.
1. **OpenTelemetry SDK** plugar quando entrar no catálogo.
2. **Design C2/C4** (Consulta de Clientes + Lançamento de Pedido) — após resolver OQ-1 e OQ-4 com o primeiro cliente.
### 2026-05-27 — Master-login stub + WorkspacePrismaPool COMPLETO ✅
**Entregas (M1M7):**
- **M1 — Prisma 7 config corrigida:** `prisma.config.ts` usa `datasource.url` (não `migrate.adapter`) — API correta do Prisma 7. `prisma migrate dev` e `prisma generate` funcionando. Schema vazio (modelos virão com C2/C3).
- **M2 — WorkspacePrismaPool:** LRU cache (max 10) de `PrismaClient` por `workspaceId`. `getOrCreate(workspaceId, dbUrl)`, `health(k=3)`, `onModuleDestroy`. Usa `@prisma/adapter-pg` + `pg.Pool` por workspace (ADR 0006).
- **M3 — JwtAuthGuard:** Guard global (`APP_GUARD`) com `jose` HS256. Valida Bearer token, popula `req.user` com `{sub, workspace_id, role}`. Atualiza CLS com `workspaceId`, `userId` e `prisma` após validação. `@Public()` decorator para ping/health/dev-auth.
- **M4 — Auth dev stub:** `POST /api/v1/auth/dev/token` — emite JWT HS256 com claims `{sub, workspace_id, role}`. Retorna 404 em produção. Contrato `DevTokenRequestSchema` + `AuthTokenResponseSchema` em `@sar/api-interface`.
- **M5 — WorkspaceModule:** CLS setup simplificado (middleware só define `requestId` + `workspaceId` default). Guard sobrescreve workspace real do JWT. Pool não injetado no middleware (limitação do nestjs-cls `ClsRootModule`).
- **M6 — Health ready:** `WorkspacePoolHealthIndicator` adicionado ao `/health/ready`. Amostra top-3 LRU — nunca O(N). `active: 0` quando nenhum workspace criado ainda.
- **M7 — Smoke test:** API sobe limpo. `/health/live` ✓, `/health/ready` ✓ (pool ativo=0), `/ping` público ✓, `POST /auth/dev/token` emite token com claims corretos.
**Decisões técnicas:**
- `@prisma/client-runtime-utils` adicionado como dependência direta no workspace root (pnpm isolated mode não o hoista automaticamente).
- Guard atualiza CLS depois do middleware (ordem correta NestJS: middleware → guard → handler).
- Pool não injetado no ClsRootAsync devido a limitação de DI do nestjs-cls; guard faz a resolução.
**Pendente próxima sessão:**
1. **OpenTelemetry SDK** plugar quando entrar no catálogo.
2. **Modelagem C2** — modelo `Client` no Prisma schema + migração + endpoint `GET /clients`. Requer OQ-1/OQ-4 resolvidos com primeiro cliente.
---

View File

@@ -1 +1,2 @@
export * from './lib/ping.contract';
export * from './lib/auth.contract';

View File

@@ -0,0 +1,23 @@
import { z } from 'zod';
// Contrato do auth dev stub — POST /api/v1/auth/dev/token.
// Endpoint existe APENAS em development/test (NODE_ENV !== 'production').
// CODING-RULES PGD-SEC-002: never use dev secret in production.
const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
export const DevTokenRequestSchema = z.object({
userId: z.string().min(1),
workspaceId: z.string().min(1),
role: JwtRoleSchema,
});
export const AuthTokenResponseSchema = z.object({
accessToken: z.string().min(1),
tokenType: z.literal('Bearer'),
expiresIn: z.number().int().positive(),
});
export type DevTokenRequest = z.infer<typeof DevTokenRequestSchema>;
export type AuthTokenResponse = z.infer<typeof AuthTokenResponseSchema>;
export type JwtRole = z.infer<typeof JwtRoleSchema>;

View File

@@ -47,6 +47,7 @@
"@nx/webpack": "22.7.4",
"@nx/workspace": "^22.7.4",
"@playwright/test": "^1.36.0",
"@prisma/client-runtime-utils": "7.8.0",
"@swc-node/register": "~1.11.1",
"@swc/cli": "~0.8.0",
"@swc/core": "~1.15.5",
@@ -57,6 +58,7 @@
"@types/express": "^5.0.6",
"@types/jest": "~30.0.0",
"@types/node": "catalog:",
"@types/pg": "^8.20.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.0",
@@ -97,15 +99,21 @@
"@nestjs/core": "^11.0.0",
"@nestjs/platform-express": "^11.0.0",
"@nestjs/terminus": "^11.1.1",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"axios": "^1.6.0",
"compression": "^1.8.1",
"helmet": "^8.2.0",
"jose": "^6.2.3",
"lru-cache": "^11.5.0",
"nestjs-cls": "^5.4.3",
"nestjs-pino": "^4.6.1",
"nestjs-zod": "^4.3.1",
"pg": "^8.21.0",
"pino": "^9.14.0",
"pino-http": "^10.5.0",
"pino-pretty": "^13.1.3",
"prisma": "^7.8.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"reflect-metadata": "^0.1.13",

782
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,9 +6,11 @@ packages:
allowBuilds:
'@nestjs/core': true
'@parcel/watcher': true
'@prisma/engines': true
'@swc/core': true
less: true
nx: true
prisma: true
unrs-resolver: true
catalog: