Auth completo (Argon2id + JWT/refresh rotativo), permissões, Companies e OpenAPI

- Auth (apps/api/src/auth/): Argon2id com parâmetros versionados
  (ADR-0005), access token JWT de 15min, refresh token opaco de 256 bits
  com rotação e detecção de replay (reuso de token já rotacionado revoga
  toda a cadeia de sessões do usuário). Endpoints /api/v1/auth/
  {login,refresh,logout,me}.
- Permissões resource+action+scope (ADR-0004) via PermissionGuard,
  resolvidas do banco a cada request (nunca cacheadas no JWT) — mudança
  de papel tem efeito imediato. super_admin com bypass hardcoded,
  espelhando a trava já existente no banco.
- Companies (migration + CRUD): leitura aberta a qualquer autenticado,
  escrita restrita a companies:manage; validação de CNPJ via
  packages/domain-shared (dígito verificador).
- SeedService: cria o primeiro super_admin a partir de
  EDEN_SUPERADMIN_EMAIL/PASSWORD só quando `users` está vazia.
- OpenAPI/Swagger em /api/docs; ValidationPipe global com
  whitelist+forbidNonWhitelisted (proteção contra mass-assignment).
- packages/database: repositórios (roles/users/sessions/audit/companies)
  reutilizáveis por api e worker.

Validado manualmente contra a stack real (não só typecheck): login com
senha errada → 401 genérico; refresh rotaciona e invalida o token
anterior; reuso do token antigo → replay detectado, cadeia revogada;
logout idempotente; audit log com a sequência completa de eventos;
CNPJ inválido rejeitado; mass-assignment rejeitado.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 09:25:48 -03:00
parent 44510bd019
commit 8d81cf506b
42 changed files with 1804 additions and 33 deletions

View File

@@ -11,17 +11,25 @@
"test": "echo 'no tests yet'"
},
"dependencies": {
"@eden/config": "workspace:*",
"@eden/database": "workspace:*",
"@eden/domain-shared": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/swagger": "^8.1.0",
"@nestjs/terminus": "^10.2.3",
"argon2": "^0.41.1",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
"devDependencies": {
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"

View File

@@ -1,9 +1,13 @@
import { Module } from "@nestjs/common";
import { TerminusModule } from "@nestjs/terminus";
import { HealthController } from "./health/health.controller";
import { HealthController } from "./health/health.controller.js";
import { ConfigModule } from "./config/config.module.js";
import { AuthModule } from "./auth/auth.module.js";
import { BootstrapModule } from "./bootstrap/bootstrap.module.js";
import { CompaniesModule } from "./companies/companies.module.js";
@Module({
imports: [TerminusModule],
imports: [ConfigModule, TerminusModule, AuthModule, BootstrapModule, CompaniesModule],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,47 @@
import { Body, Controller, Get, HttpCode, Post, Req, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import { AuthService } from "./auth.service.js";
import { LoginDto } from "./dto/login.dto.js";
import { RefreshDto } from "./dto/refresh.dto.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { CurrentUser } from "./decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "./jwt-auth.guard.js";
function requestContext(req: Request) {
return {
ipAddress: req.ip ?? null,
userAgent: req.headers["user-agent"] ?? null,
};
}
@ApiTags("auth")
@Controller("v1/auth")
export class AuthController {
constructor(private readonly auth: AuthService) {}
@Post("login")
@HttpCode(200)
async login(@Body() dto: LoginDto, @Req() req: Request) {
return this.auth.login(dto.email, dto.password, dto.applicationKey, requestContext(req));
}
@Post("refresh")
@HttpCode(200)
async refresh(@Body() dto: RefreshDto, @Req() req: Request) {
return this.auth.refresh(dto.refreshToken, requestContext(req));
}
@Post("logout")
@HttpCode(204)
async logout(@Body() dto: RefreshDto, @Req() req: Request) {
await this.auth.logout(dto.refreshToken, requestContext(req));
}
@Get("me")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
async me(@CurrentUser() user: EdenAuthenticatedUser) {
return this.auth.getProfile(user.id);
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthController } from "./auth.controller.js";
import { AuthService } from "./auth.service.js";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { JwtAuthGuard } from "./jwt-auth.guard.js";
import { PermissionGuard } from "./permission.guard.js";
@Module({
controllers: [AuthController],
providers: [AuthService, PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
exports: [PasswordService, TokenService, JwtAuthGuard, PermissionGuard],
})
export class AuthModule {}

View File

@@ -0,0 +1,181 @@
import { ForbiddenException, Injectable, UnauthorizedException } from "@nestjs/common";
import { usersRepo, sessionsRepo, auditRepo, type User } from "@eden/database";
import { PasswordService } from "./password.service.js";
import { TokenService } from "./token.service.js";
import { resolvePermissions } from "./permissions.js";
export interface RequestContext {
ipAddress?: string | null;
userAgent?: string | null;
}
export interface AuthResult {
accessToken: string;
expiresIn: number;
refreshToken: string;
user: PublicUser;
}
export interface PublicUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
}
function toPublicUser(user: User): PublicUser {
return { id: user.id, email: user.email, fullName: user.full_name, roleKey: user.role_key };
}
const GENERIC_LOGIN_ERROR = "Credenciais inválidas";
@Injectable()
export class AuthService {
constructor(
private readonly passwords: PasswordService,
private readonly tokens: TokenService,
) {}
async login(
email: string,
password: string,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
): Promise<AuthResult> {
const user = await usersRepo.findUserByEmail(email);
// Same generic error whether the account doesn't exist or the password
// is wrong — anti-enumeration, mirrors eden.md §4.1.
if (!user || !user.password_hash) {
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
const valid = await this.passwords.verify(user.password_hash, password);
if (!valid) {
throw new UnauthorizedException(GENERIC_LOGIN_ERROR);
}
if (this.passwords.needsRehash(user.password_algo_version)) {
const { hash, algoVersion } = await this.passwords.hash(password);
await usersRepo.updateUserPassword(user.id, hash, algoVersion);
}
const grantedApps = await usersRepo.getUserApplications(user.id);
if (!grantedApps.includes(applicationKey)) {
throw new ForbiddenException("Usuário sem acesso a esta aplicação");
}
const result = await this.issueTokens(user, applicationKey, ctx);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.login",
resourceType: "user",
resourceId: user.id,
metadata: { applicationKey },
ipAddress: ctx.ipAddress,
});
return result;
}
async refresh(refreshTokenPlaintext: string, ctx: RequestContext): Promise<AuthResult> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const active = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
if (!active) {
// Replay detection: a token that exists but is revoked/expired being
// reused is a signal the refresh token chain may be compromised —
// revoke every session of that user defensively (ADR-0003).
const anyState = await sessionsRepo.findSessionByRefreshTokenHashAnyState(hash);
if (anyState?.revoked_at) {
await sessionsRepo.revokeAllUserSessions(anyState.user_id);
await auditRepo.appendAuditEvent({
actorType: "system",
actorId: anyState.user_id,
action: "auth.refresh_replay_detected",
resourceType: "session",
resourceId: anyState.id,
ipAddress: ctx.ipAddress,
});
}
throw new UnauthorizedException("Sessão inválida ou expirada");
}
const user = await usersRepo.findUserById(active.user_id);
if (!user || !user.is_active) {
throw new UnauthorizedException("Sessão inválida ou expirada");
}
await sessionsRepo.touchSession(active.id);
const result = await this.issueTokens(user, active.application_key, ctx, active.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: user.id,
action: "auth.refresh",
resourceType: "session",
resourceId: active.id,
ipAddress: ctx.ipAddress,
});
return result;
}
async logout(refreshTokenPlaintext: string, ctx: RequestContext): Promise<void> {
const hash = this.tokens.hashRefreshToken(refreshTokenPlaintext);
const session = await sessionsRepo.findActiveSessionByRefreshTokenHash(hash);
if (!session) return; // idempotent — already logged out
await sessionsRepo.revokeSession(session.id);
await auditRepo.appendAuditEvent({
actorType: "user",
actorId: session.user_id,
action: "auth.logout",
resourceType: "session",
resourceId: session.id,
ipAddress: ctx.ipAddress,
});
}
async getProfile(userId: string) {
const user = await usersRepo.findUserById(userId);
if (!user || !user.is_active) throw new UnauthorizedException();
const permissions = await resolvePermissions(user.role_key);
const applications = await usersRepo.getUserApplications(user.id);
return {
user: toPublicUser(user),
permissions: permissions.superAdmin
? ("ALL" as const)
: permissions.grants.map((g) => ({ resource: g.resource, action: g.action, scope: g.scope })),
applications,
};
}
private async issueTokens(
user: User,
applicationKey: "core" | "reseller" | "subscriber",
ctx: RequestContext,
replacesSessionId?: string,
): Promise<AuthResult> {
const access = this.tokens.signAccessToken({ sub: user.id, app: applicationKey });
const refresh = this.tokens.generateRefreshToken();
await sessionsRepo.createSession({
userId: user.id,
applicationKey,
refreshTokenHash: refresh.hash,
expiresAt: refresh.expiresAt,
ipAddress: ctx.ipAddress,
userAgent: ctx.userAgent,
replacesSessionId,
});
return {
accessToken: access.token,
expiresIn: access.expiresIn,
refreshToken: refresh.plaintext,
user: toPublicUser(user),
};
}
}

View File

@@ -0,0 +1,7 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import type { Request } from "express";
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest<Request>();
return request.edenUser;
});

View File

@@ -0,0 +1,19 @@
import { SetMetadata } from "@nestjs/common";
import type { RolePermission } from "@eden/database";
export const REQUIRE_PERMISSION_KEY = "eden:requirePermission";
export interface RequiredPermission {
resource: string;
action: RolePermission["action"];
}
/**
* Declares that an endpoint needs (resource, action) per ADR-0004.
* super_admin always passes. Confirms the actor holds *some* scope for
* this resource/action — row-level filtering by the granted scope (own
* vs. all, etc.) is the responsibility of the service/query layer, which
* reads it off `request.edenUser.permissions` (see permissions.ts).
*/
export const RequirePermission = (resource: string, action: RequiredPermission["action"]) =>
SetMetadata(REQUIRE_PERMISSION_KEY, { resource, action } satisfies RequiredPermission);

View File

@@ -0,0 +1,13 @@
import { IsEmail, IsIn, IsString, MinLength } from "class-validator";
export class LoginDto {
@IsEmail()
email!: string;
@IsString()
@MinLength(1)
password!: string;
@IsIn(["core", "reseller", "subscriber"])
applicationKey!: "core" | "reseller" | "subscriber";
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from "class-validator";
export class RefreshDto {
@IsString()
@MinLength(32)
refreshToken!: string;
}

View File

@@ -0,0 +1,65 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { usersRepo } from "@eden/database";
import { TokenService } from "./token.service.js";
import { resolvePermissions, type ResolvedPermissions } from "./permissions.js";
export interface EdenAuthenticatedUser {
id: string;
email: string;
fullName: string | null;
roleKey: string;
applicationKey: "core" | "reseller" | "subscriber";
permissions: ResolvedPermissions;
}
declare module "express" {
interface Request {
edenUser?: EdenAuthenticatedUser;
}
}
/**
* Verifies the access token's signature/expiry, then re-resolves the user
* and their permissions from the database on every request — deliberately
* not cached in the JWT payload, so a role/permission change (or account
* deactivation) takes effect immediately rather than waiting out the
* token's 15-minute lifetime. Mirrors the legacy's LEFT JOIN-on-every-
* request philosophy (eden.md §1, §3.3).
*/
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private readonly tokens: TokenService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const header = request.headers.authorization;
if (!header?.startsWith("Bearer ")) {
throw new UnauthorizedException("Token de acesso ausente");
}
let payload;
try {
payload = this.tokens.verifyAccessToken(header.slice("Bearer ".length));
} catch {
throw new UnauthorizedException("Token de acesso inválido ou expirado");
}
const user = await usersRepo.findUserById(payload.sub);
if (!user || !user.is_active) {
throw new UnauthorizedException("Usuário inativo ou inexistente");
}
const permissions = await resolvePermissions(user.role_key);
request.edenUser = {
id: user.id,
email: user.email,
fullName: user.full_name,
roleKey: user.role_key,
applicationKey: payload.app,
permissions,
};
return true;
}
}

View File

@@ -0,0 +1,44 @@
import { Injectable } from "@nestjs/common";
import * as argon2 from "argon2";
/**
* Argon2id password hashing with versioned parameters (ADR-0005). Bumping
* CURRENT_ALGO_VERSION and its entry in PARAMS_BY_VERSION lets us raise
* cost over time without invalidating existing hashes — verification
* always uses the parameters embedded in argon2's own encoded hash
* string, and a stored user is only re-hashed with the new parameters on
* their next successful login (see needsRehash()).
*/
const PARAMS_BY_VERSION: Record<number, argon2.Options> = {
1: {
type: argon2.argon2id,
memoryCost: 19456, // 19 MiB — OWASP minimum recommendation for argon2id
timeCost: 2,
parallelism: 1,
},
};
export const CURRENT_ALGO_VERSION = 1;
@Injectable()
export class PasswordService {
async hash(plain: string): Promise<{ hash: string; algoVersion: number }> {
const params = PARAMS_BY_VERSION[CURRENT_ALGO_VERSION];
const hash = await argon2.hash(plain, params);
return { hash, algoVersion: CURRENT_ALGO_VERSION };
}
async verify(hash: string, plain: string): Promise<boolean> {
try {
return await argon2.verify(hash, plain);
} catch {
// Malformed/foreign hash — treat as verification failure, never throw
// into the caller (which would otherwise leak a 500 instead of 401).
return false;
}
}
needsRehash(algoVersion: number): boolean {
return algoVersion !== CURRENT_ALGO_VERSION;
}
}

View File

@@ -0,0 +1,36 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import type { Request } from "express";
import { REQUIRE_PERMISSION_KEY, type RequiredPermission } from "./decorators/require-permission.decorator.js";
import { hasPermission } from "./permissions.js";
/**
* Runs after JwtAuthGuard (which populates request.edenUser). Endpoints
* without @RequirePermission are left alone — authentication only.
*/
@Injectable()
export class PermissionGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const required = this.reflector.get<RequiredPermission | undefined>(
REQUIRE_PERMISSION_KEY,
context.getHandler(),
);
if (!required) return true;
const request = context.switchToHttp().getRequest<Request>();
const user = request.edenUser;
if (!user) {
throw new ForbiddenException("Autenticação necessária");
}
if (!hasPermission(user.permissions, required.resource, required.action)) {
throw new ForbiddenException(
`Sem permissão para '${required.action}' em '${required.resource}'`,
);
}
return true;
}
}

View File

@@ -0,0 +1,55 @@
import { rolesRepo, type RolePermission } from "@eden/database";
export const SUPER_ADMIN_ROLE = "super_admin";
export type ResolvedPermissions =
| { superAdmin: true }
| { superAdmin: false; grants: RolePermission[] };
/**
* super_admin is never looked up in role_permissions — hardcoded bypass,
* mirrored from the legacy system and enforced again at the DB level
* (docs/adr/0004-permission-model.md, chk_role_permissions_no_super_admin).
*/
export async function resolvePermissions(roleKey: string): Promise<ResolvedPermissions> {
if (roleKey === SUPER_ADMIN_ROLE) return { superAdmin: true };
const grants = await rolesRepo.getRolePermissions(roleKey);
return { superAdmin: false, grants };
}
/**
* Returns the maximum scope the actor holds for (resource, action), or
* null if they hold none. Scope precedence — broader wins when a role
* somehow has more than one row for the same resource+action (shouldn't
* happen given the UNIQUE constraint per scope, but a role could have
* both e.g. 'own' and 'all' rows intentionally):
*/
const SCOPE_RANK: Record<RolePermission["scope"], number> = {
own: 1,
team: 2,
reseller: 3,
legal_entity: 4,
all: 5,
};
export function maxGrantedScope(
permissions: ResolvedPermissions,
resource: string,
action: RolePermission["action"],
): RolePermission["scope"] | "all" | null {
if (permissions.superAdmin) return "all";
const matches = permissions.grants.filter((g) => g.resource === resource && g.action === action);
if (matches.length === 0) return null;
return matches.reduce<RolePermission["scope"]>(
(best, g) => (SCOPE_RANK[g.scope] > SCOPE_RANK[best] ? g.scope : best),
matches[0]!.scope,
);
}
export function hasPermission(
permissions: ResolvedPermissions,
resource: string,
action: RolePermission["action"],
): boolean {
return maxGrantedScope(permissions, resource, action) !== null;
}

View File

@@ -0,0 +1,45 @@
import { randomBytes, createHash } from "node:crypto";
import { Inject, Injectable } from "@nestjs/common";
import jwt from "jsonwebtoken";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
export interface AccessTokenPayload {
sub: string; // user id
app: "core" | "reseller" | "subscriber";
}
const ACCESS_TOKEN_TTL_SECONDS = 15 * 60; // 15 min — ADR-0003
const REFRESH_TOKEN_TTL_DAYS = 30;
@Injectable()
export class TokenService {
private readonly secret: string;
constructor(@Inject(EDEN_ENV) env: EdenEnv) {
this.secret = env.JWT_SIGNING_SECRET;
}
signAccessToken(payload: AccessTokenPayload): { token: string; expiresIn: number } {
const token = jwt.sign(payload, this.secret, { expiresIn: ACCESS_TOKEN_TTL_SECONDS });
return { token, expiresIn: ACCESS_TOKEN_TTL_SECONDS };
}
verifyAccessToken(token: string): AccessTokenPayload {
return jwt.verify(token, this.secret) as AccessTokenPayload;
}
/** Opaque, high-entropy refresh token — never a JWT (ADR-0003). Returns the
* plaintext (shown once to the client) and its SHA-256 hash (the only
* thing persisted). */
generateRefreshToken(): { plaintext: string; hash: string; expiresAt: Date } {
const plaintext = randomBytes(32).toString("hex"); // 256 bits
const hash = this.hashRefreshToken(plaintext);
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
return { plaintext, hash, expiresAt };
}
hashRefreshToken(plaintext: string): string {
return createHash("sha256").update(plaintext).digest("hex");
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { SeedService } from "./seed.service.js";
@Module({
imports: [AuthModule],
providers: [SeedService],
})
export class BootstrapModule {}

View File

@@ -0,0 +1,50 @@
import { Inject, Injectable, Logger, OnModuleInit } from "@nestjs/common";
import { usersRepo } from "@eden/database";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import { PasswordService } from "../auth/password.service.js";
import { SUPER_ADMIN_ROLE } from "../auth/permissions.js";
/**
* Mirrors the legacy's seedAdmin() (eden.md §5.7): runs once at boot,
* only acts if `users` is empty, creates the first super_admin from env
* vars — never hardcoded, never auto-generated-and-logged. If the env
* vars aren't set, boot proceeds without seeding (useful for CI/tests
* that don't need a real login) rather than crashing.
*/
@Injectable()
export class SeedService implements OnModuleInit {
private readonly logger = new Logger(SeedService.name);
constructor(
@Inject(EDEN_ENV) private readonly env: EdenEnv,
private readonly passwords: PasswordService,
) {}
async onModuleInit() {
const existing = await usersRepo.countUsers();
if (existing > 0) return;
if (!this.env.EDEN_SUPERADMIN_EMAIL || !this.env.EDEN_SUPERADMIN_PASSWORD) {
this.logger.warn(
"users está vazia e EDEN_SUPERADMIN_EMAIL/EDEN_SUPERADMIN_PASSWORD não estão definidos — pulando seed do super_admin.",
);
return;
}
const { hash, algoVersion } = await this.passwords.hash(this.env.EDEN_SUPERADMIN_PASSWORD);
const user = await usersRepo.createUser({
email: this.env.EDEN_SUPERADMIN_EMAIL,
passwordHash: hash,
passwordAlgoVersion: algoVersion,
roleKey: SUPER_ADMIN_ROLE,
fullName: "Super Administrador",
});
await usersRepo.grantUserApplication(user.id, "core");
await usersRepo.grantUserApplication(user.id, "reseller");
await usersRepo.grantUserApplication(user.id, "subscriber");
this.logger.log(`super_admin inicial criado (${user.email}) a partir de EDEN_SUPERADMIN_EMAIL.`);
}
}

View File

@@ -0,0 +1,50 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { CompaniesService } from "./companies.service.js";
import { CreateCompanyDto } from "./dto/create-company.dto.js";
import { UpdateCompanyDto } from "./dto/update-company.dto.js";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
/**
* Read is open to any authenticated user (not gated by a resource
* permission) — company data isn't sensitive and is needed to render
* contracts/documents from screens any seller/backoffice user can reach,
* mirroring the legacy (eden.md §1, §6). Write requires companies:manage.
*/
@ApiTags("companies")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/companies")
export class CompaniesController {
constructor(private readonly companies: CompaniesService) {}
@Get()
list(@Query("includeInactive") includeInactive?: string) {
return this.companies.list(includeInactive === "true");
}
@Get(":id")
get(@Param("id") id: string) {
return this.companies.get(id);
}
@Post()
@RequirePermission("companies", "manage")
create(@Body() dto: CreateCompanyDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.companies.create(dto, user.id);
}
@Patch(":id")
@RequirePermission("companies", "manage")
update(
@Param("id") id: string,
@Body() dto: UpdateCompanyDto,
@CurrentUser() user: EdenAuthenticatedUser,
) {
return this.companies.update(id, dto, user.id);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { CompaniesController } from "./companies.controller.js";
import { CompaniesService } from "./companies.service.js";
@Module({
imports: [AuthModule],
controllers: [CompaniesController],
providers: [CompaniesService],
})
export class CompaniesModule {}

View File

@@ -0,0 +1,102 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import { companiesRepo, auditRepo, type Company } from "@eden/database";
import { onlyDigits, isValidCNPJ } from "@eden/domain-shared";
import type { CreateCompanyDto } from "./dto/create-company.dto.js";
import type { UpdateCompanyDto } from "./dto/update-company.dto.js";
@Injectable()
export class CompaniesService {
list(includeInactive: boolean): Promise<Company[]> {
return companiesRepo.listCompanies(includeInactive);
}
async get(id: string): Promise<Company> {
const company = await companiesRepo.getCompanyById(id);
if (!company) throw new NotFoundException("Empresa não encontrada");
return company;
}
async create(dto: CreateCompanyDto, actorId: string): Promise<Company> {
this.assertValidCnpjIfPresent(dto.cnpj);
const company = await companiesRepo.createCompany({
companyName: dto.companyName,
tradeName: dto.tradeName,
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
stateRegistration: dto.stateRegistration,
municipalRegistration: dto.municipalRegistration,
addressZip: dto.addressZip,
addressStreet: dto.addressStreet,
addressNumber: dto.addressNumber,
addressComplement: dto.addressComplement,
addressNeighborhood: dto.addressNeighborhood,
addressCity: dto.addressCity,
addressState: dto.addressState,
addressCountry: dto.addressCountry,
phone: dto.phone,
email: dto.email,
website: dto.website,
legalRepName: dto.legalRepName,
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
legalRepRole: dto.legalRepRole,
legalRepEmail: dto.legalRepEmail,
createdBy: actorId,
});
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "company.created",
resourceType: "company",
resourceId: company.id,
});
return company;
}
async update(id: string, dto: UpdateCompanyDto, actorId: string): Promise<Company> {
await this.get(id); // 404 if missing
this.assertValidCnpjIfPresent(dto.cnpj);
const company = await companiesRepo.updateCompany(id, {
companyName: dto.companyName,
tradeName: dto.tradeName,
cnpj: dto.cnpj ? onlyDigits(dto.cnpj) : undefined,
stateRegistration: dto.stateRegistration,
municipalRegistration: dto.municipalRegistration,
addressZip: dto.addressZip,
addressStreet: dto.addressStreet,
addressNumber: dto.addressNumber,
addressComplement: dto.addressComplement,
addressNeighborhood: dto.addressNeighborhood,
addressCity: dto.addressCity,
addressState: dto.addressState,
addressCountry: dto.addressCountry,
phone: dto.phone,
email: dto.email,
website: dto.website,
legalRepName: dto.legalRepName,
legalRepCpf: dto.legalRepCpf ? onlyDigits(dto.legalRepCpf) : undefined,
legalRepRole: dto.legalRepRole,
legalRepEmail: dto.legalRepEmail,
isActive: dto.isActive,
});
if (!company) throw new NotFoundException("Empresa não encontrada");
await auditRepo.appendAuditEvent({
actorType: "user",
actorId,
action: "company.updated",
resourceType: "company",
resourceId: id,
metadata: { fields: Object.keys(dto) },
});
return company;
}
private assertValidCnpjIfPresent(cnpj: string | undefined): void {
if (cnpj && !isValidCNPJ(cnpj)) {
throw new BadRequestException("CNPJ inválido");
}
}
}

View File

@@ -0,0 +1,27 @@
import { IsEmail, IsOptional, IsString, MinLength } from "class-validator";
export class CreateCompanyDto {
@IsString()
@MinLength(1)
companyName!: string;
@IsOptional() @IsString() tradeName?: string;
@IsOptional() @IsString() cnpj?: string;
@IsOptional() @IsString() stateRegistration?: string;
@IsOptional() @IsString() municipalRegistration?: string;
@IsOptional() @IsString() addressZip?: string;
@IsOptional() @IsString() addressStreet?: string;
@IsOptional() @IsString() addressNumber?: string;
@IsOptional() @IsString() addressComplement?: string;
@IsOptional() @IsString() addressNeighborhood?: string;
@IsOptional() @IsString() addressCity?: string;
@IsOptional() @IsString() addressState?: string;
@IsOptional() @IsString() addressCountry?: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsEmail() email?: string;
@IsOptional() @IsString() website?: string;
@IsOptional() @IsString() legalRepName?: string;
@IsOptional() @IsString() legalRepCpf?: string;
@IsOptional() @IsString() legalRepRole?: string;
@IsOptional() @IsEmail() legalRepEmail?: string;
}

View File

@@ -0,0 +1,9 @@
import { PartialType } from "@nestjs/swagger";
import { IsOptional, IsBoolean } from "class-validator";
import { CreateCompanyDto } from "./create-company.dto.js";
export class UpdateCompanyDto extends PartialType(CreateCompanyDto) {
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1 @@
export const EDEN_ENV = Symbol("EDEN_ENV");

View File

@@ -0,0 +1,19 @@
import { Global, Module } from "@nestjs/common";
import { loadEnv } from "@eden/config";
import { EDEN_ENV } from "./config.constants.js";
/**
* Fails fast at boot (via loadEnv) if a required secret/config var is
* missing — see packages/config/src/env-schema.ts and eden.md §5.6.
*/
@Global()
@Module({
providers: [
{
provide: EDEN_ENV,
useFactory: () => loadEnv(),
},
],
exports: [EDEN_ENV],
})
export class ConfigModule {}

View File

@@ -1,13 +1,38 @@
import "reflect-metadata";
import { NestFactory } from "@nestjs/core";
import { ValidationPipe } from "@nestjs/common";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.setGlobalPrefix("api", { exclude: ["health/live", "health/ready"] });
// Server-side validation is the authority — never trust that a client
// already validated (Master Prompt §12). Strips unknown fields instead
// of accepting them silently (mass-assignment protection).
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
const openApiConfig = new DocumentBuilder()
.setTitle("EDEN API")
.setDescription("API principal do EDEN (Core, Parceiros, Assinante)")
.setVersion("0.1.0")
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, openApiConfig);
SwaggerModule.setup("api/docs", app, document);
const port = Number(process.env.EDEN_API_PORT ?? 8080);
await app.listen(port, "0.0.0.0");
// eslint-disable-next-line no-console
console.log(`[eden-api] listening on :${port}`);
console.log(`[eden-api] listening on :${port} — docs em /api/docs`);
}
bootstrap();