From 8d81cf506b288c8e5919f6ff646923aa4f913228 Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Thu, 3 Sep 2026 09:25:48 -0300 Subject: [PATCH] =?UTF-8?q?Auth=20completo=20(Argon2id=20+=20JWT/refresh?= =?UTF-8?q?=20rotativo),=20permiss=C3=B5es,=20Companies=20e=20OpenAPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/api/package.json | 8 + apps/api/src/app.module.ts | 8 +- apps/api/src/auth/auth.controller.ts | 47 +++ apps/api/src/auth/auth.module.ts | 14 + apps/api/src/auth/auth.service.ts | 181 ++++++++++ .../auth/decorators/current-user.decorator.ts | 7 + .../require-permission.decorator.ts | 19 ++ apps/api/src/auth/dto/login.dto.ts | 13 + apps/api/src/auth/dto/refresh.dto.ts | 7 + apps/api/src/auth/jwt-auth.guard.ts | 65 ++++ apps/api/src/auth/password.service.ts | 44 +++ apps/api/src/auth/permission.guard.ts | 36 ++ apps/api/src/auth/permissions.ts | 55 +++ apps/api/src/auth/token.service.ts | 45 +++ apps/api/src/bootstrap/bootstrap.module.ts | 9 + apps/api/src/bootstrap/seed.service.ts | 50 +++ .../api/src/companies/companies.controller.ts | 50 +++ apps/api/src/companies/companies.module.ts | 11 + apps/api/src/companies/companies.service.ts | 102 ++++++ .../src/companies/dto/create-company.dto.ts | 27 ++ .../src/companies/dto/update-company.dto.ts | 9 + apps/api/src/config/config.constants.ts | 1 + apps/api/src/config/config.module.ts | 19 ++ apps/api/src/main.ts | 27 +- compose.yaml | 2 + docs/progress.md | 18 +- packages/config/package.json | 4 + packages/config/src/env-schema.ts | 50 +++ packages/config/src/index.ts | 7 +- .../migrations/1788437478189_companies.cjs | 54 +++ packages/database/package.json | 1 + packages/database/src/index.ts | 7 + packages/database/src/repositories/audit.ts | 65 ++++ .../database/src/repositories/companies.ts | 163 +++++++++ packages/database/src/repositories/roles.ts | 20 ++ .../database/src/repositories/sessions.ts | 84 +++++ packages/database/src/repositories/types.ts | 53 +++ packages/database/src/repositories/users.ts | 80 +++++ packages/domain-shared/src/index.ts | 7 +- .../src/validators/br-documents.ts | 49 +++ pnpm-lock.yaml | 317 +++++++++++++++++- pnpm-workspace.yaml | 2 + 42 files changed, 1804 insertions(+), 33 deletions(-) create mode 100644 apps/api/src/auth/auth.controller.ts create mode 100644 apps/api/src/auth/auth.module.ts create mode 100644 apps/api/src/auth/auth.service.ts create mode 100644 apps/api/src/auth/decorators/current-user.decorator.ts create mode 100644 apps/api/src/auth/decorators/require-permission.decorator.ts create mode 100644 apps/api/src/auth/dto/login.dto.ts create mode 100644 apps/api/src/auth/dto/refresh.dto.ts create mode 100644 apps/api/src/auth/jwt-auth.guard.ts create mode 100644 apps/api/src/auth/password.service.ts create mode 100644 apps/api/src/auth/permission.guard.ts create mode 100644 apps/api/src/auth/permissions.ts create mode 100644 apps/api/src/auth/token.service.ts create mode 100644 apps/api/src/bootstrap/bootstrap.module.ts create mode 100644 apps/api/src/bootstrap/seed.service.ts create mode 100644 apps/api/src/companies/companies.controller.ts create mode 100644 apps/api/src/companies/companies.module.ts create mode 100644 apps/api/src/companies/companies.service.ts create mode 100644 apps/api/src/companies/dto/create-company.dto.ts create mode 100644 apps/api/src/companies/dto/update-company.dto.ts create mode 100644 apps/api/src/config/config.constants.ts create mode 100644 apps/api/src/config/config.module.ts create mode 100644 packages/config/src/env-schema.ts create mode 100644 packages/database/migrations/1788437478189_companies.cjs create mode 100644 packages/database/src/repositories/audit.ts create mode 100644 packages/database/src/repositories/companies.ts create mode 100644 packages/database/src/repositories/roles.ts create mode 100644 packages/database/src/repositories/sessions.ts create mode 100644 packages/database/src/repositories/types.ts create mode 100644 packages/database/src/repositories/users.ts create mode 100644 packages/domain-shared/src/validators/br-documents.ts diff --git a/apps/api/package.json b/apps/api/package.json index 5e6d31b..dd4ef3b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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" diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 4d316d1..69fe418 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts new file mode 100644 index 0000000..eb7ecba --- /dev/null +++ b/apps/api/src/auth/auth.controller.ts @@ -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); + } +} diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts new file mode 100644 index 0000000..62c64f4 --- /dev/null +++ b/apps/api/src/auth/auth.module.ts @@ -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 {} diff --git a/apps/api/src/auth/auth.service.ts b/apps/api/src/auth/auth.service.ts new file mode 100644 index 0000000..1d6eadc --- /dev/null +++ b/apps/api/src/auth/auth.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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), + }; + } +} diff --git a/apps/api/src/auth/decorators/current-user.decorator.ts b/apps/api/src/auth/decorators/current-user.decorator.ts new file mode 100644 index 0000000..9130642 --- /dev/null +++ b/apps/api/src/auth/decorators/current-user.decorator.ts @@ -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(); + return request.edenUser; +}); diff --git a/apps/api/src/auth/decorators/require-permission.decorator.ts b/apps/api/src/auth/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..b8efd20 --- /dev/null +++ b/apps/api/src/auth/decorators/require-permission.decorator.ts @@ -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); diff --git a/apps/api/src/auth/dto/login.dto.ts b/apps/api/src/auth/dto/login.dto.ts new file mode 100644 index 0000000..1d39f77 --- /dev/null +++ b/apps/api/src/auth/dto/login.dto.ts @@ -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"; +} diff --git a/apps/api/src/auth/dto/refresh.dto.ts b/apps/api/src/auth/dto/refresh.dto.ts new file mode 100644 index 0000000..ec95ff3 --- /dev/null +++ b/apps/api/src/auth/dto/refresh.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from "class-validator"; + +export class RefreshDto { + @IsString() + @MinLength(32) + refreshToken!: string; +} diff --git a/apps/api/src/auth/jwt-auth.guard.ts b/apps/api/src/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..339ef72 --- /dev/null +++ b/apps/api/src/auth/jwt-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/api/src/auth/password.service.ts b/apps/api/src/auth/password.service.ts new file mode 100644 index 0000000..1215948 --- /dev/null +++ b/apps/api/src/auth/password.service.ts @@ -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 = { + 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 { + 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; + } +} diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts new file mode 100644 index 0000000..fda3385 --- /dev/null +++ b/apps/api/src/auth/permission.guard.ts @@ -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( + REQUIRE_PERMISSION_KEY, + context.getHandler(), + ); + if (!required) return true; + + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/api/src/auth/permissions.ts b/apps/api/src/auth/permissions.ts new file mode 100644 index 0000000..1cc9295 --- /dev/null +++ b/apps/api/src/auth/permissions.ts @@ -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 { + 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 = { + 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( + (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; +} diff --git a/apps/api/src/auth/token.service.ts b/apps/api/src/auth/token.service.ts new file mode 100644 index 0000000..3a86517 --- /dev/null +++ b/apps/api/src/auth/token.service.ts @@ -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"); + } +} diff --git a/apps/api/src/bootstrap/bootstrap.module.ts b/apps/api/src/bootstrap/bootstrap.module.ts new file mode 100644 index 0000000..ab94f8d --- /dev/null +++ b/apps/api/src/bootstrap/bootstrap.module.ts @@ -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 {} diff --git a/apps/api/src/bootstrap/seed.service.ts b/apps/api/src/bootstrap/seed.service.ts new file mode 100644 index 0000000..0fdc746 --- /dev/null +++ b/apps/api/src/bootstrap/seed.service.ts @@ -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.`); + } +} diff --git a/apps/api/src/companies/companies.controller.ts b/apps/api/src/companies/companies.controller.ts new file mode 100644 index 0000000..4771ddd --- /dev/null +++ b/apps/api/src/companies/companies.controller.ts @@ -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); + } +} diff --git a/apps/api/src/companies/companies.module.ts b/apps/api/src/companies/companies.module.ts new file mode 100644 index 0000000..db7e3b1 --- /dev/null +++ b/apps/api/src/companies/companies.module.ts @@ -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 {} diff --git a/apps/api/src/companies/companies.service.ts b/apps/api/src/companies/companies.service.ts new file mode 100644 index 0000000..6dcd615 --- /dev/null +++ b/apps/api/src/companies/companies.service.ts @@ -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 { + return companiesRepo.listCompanies(includeInactive); + } + + async get(id: string): Promise { + const company = await companiesRepo.getCompanyById(id); + if (!company) throw new NotFoundException("Empresa não encontrada"); + return company; + } + + async create(dto: CreateCompanyDto, actorId: string): Promise { + 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 { + 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"); + } + } +} diff --git a/apps/api/src/companies/dto/create-company.dto.ts b/apps/api/src/companies/dto/create-company.dto.ts new file mode 100644 index 0000000..39c9ece --- /dev/null +++ b/apps/api/src/companies/dto/create-company.dto.ts @@ -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; +} diff --git a/apps/api/src/companies/dto/update-company.dto.ts b/apps/api/src/companies/dto/update-company.dto.ts new file mode 100644 index 0000000..ec9aa18 --- /dev/null +++ b/apps/api/src/companies/dto/update-company.dto.ts @@ -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; +} diff --git a/apps/api/src/config/config.constants.ts b/apps/api/src/config/config.constants.ts new file mode 100644 index 0000000..865a2be --- /dev/null +++ b/apps/api/src/config/config.constants.ts @@ -0,0 +1 @@ +export const EDEN_ENV = Symbol("EDEN_ENV"); diff --git a/apps/api/src/config/config.module.ts b/apps/api/src/config/config.module.ts new file mode 100644 index 0000000..844e3a1 --- /dev/null +++ b/apps/api/src/config/config.module.ts @@ -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 {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 117aac5..2f95223 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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(); diff --git a/compose.yaml b/compose.yaml index 06a7fba..d2048b7 100644 --- a/compose.yaml +++ b/compose.yaml @@ -48,6 +48,8 @@ services: JWT_SIGNING_SECRET: ${JWT_SIGNING_SECRET} SIGNATURE_OTP_SECRET: ${SIGNATURE_OTP_SECRET} EDEN_FIELD_ENCRYPTION_KEY: ${EDEN_FIELD_ENCRYPTION_KEY} + EDEN_SUPERADMIN_EMAIL: ${EDEN_SUPERADMIN_EMAIL} + EDEN_SUPERADMIN_PASSWORD: ${EDEN_SUPERADMIN_PASSWORD} SMTP_HOST: ${SMTP_HOST} SMTP_PORT: ${SMTP_PORT} SMTP_USER: ${SMTP_USER} diff --git a/docs/progress.md b/docs/progress.md index 58c5f66..8ccd2f3 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -74,10 +74,20 @@ Bugs de bootstrap corrigidos nesta sessão (documentados aqui para não se repet 3. `node-pg-migrate --envPath` não carregava o `.env` corretamente nesta versão — trocado por `bash -c 'source .env; ...'` nos scripts `migrate:up`/`migrate:down`. 4. Healthcheck dos 3 frontends usava `wget http://localhost/...`, que resolve para `::1` (IPv6) dentro do container — nginx só escuta IPv4; trocado para `127.0.0.1`. +### Auth, permissões, Companies e OpenAPI — implementados e testados ao vivo + +- **Auth** (`apps/api/src/auth/`): Argon2id (parâmetros versionados, ADR-0005), JWT de acesso de 15 min, refresh token opaco (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 `POST /api/v1/auth/{login,refresh,logout}` e `GET /api/v1/auth/me`. +- **Permissões** (`apps/api/src/auth/permissions.ts`, `permission.guard.ts`): resource+action+scope (ADR-0004), `super_admin` com bypass hardcoded (nunca consulta `role_permissions`, espelhando a trava também existente no banco). Guard resolve permissões do banco a cada request (nunca cacheado no JWT) — mudança de papel tem efeito imediato, mesma filosofia do legado. +- **Companies** (`apps/api/src/companies/` + migration `1788437478189_companies`): CRUD com leitura aberta a qualquer autenticado e escrita restrita a `companies:manage`; validação de CNPJ (dígito verificador, `packages/domain-shared`) testada com CNPJ válido/inválido conhecidos. +- **Seed automático**: `SeedService` cria o primeiro `super_admin` a partir de `EDEN_SUPERADMIN_EMAIL`/`PASSWORD` só quando `users` está vazia — testado (log `super_admin inicial criado`). +- **OpenAPI/Swagger**: `/api/docs` (UI) e `/api/docs-json`, cobrindo todos os endpoints atuais. +- **Validação/segurança de request**: `ValidationPipe` global com `whitelist`+`forbidNonWhitelisted` (proteção contra mass-assignment) — testado (`id`/`isActive` extras num POST de company são rejeitados com 400). + +Testes manuais realizados contra a stack real (não só typecheck): login com senha errada → 401 genérico; refresh rotaciona e o token antigo some; reuso do token antigo → 401 + toda a cadeia revogada; logout revoga e é idempotente; audit log com a sequência completa de eventos (`auth.login`, `auth.refresh`, `auth.refresh_replay_detected`, `company.created`). + ### Pendências da Fase 1 (não iniciadas) -- Módulo de autenticação real (Argon2id, JWT curto + refresh rotativo, MFA) — ADR-0003 desenhada, implementação ainda não começou. -- Companies/Legal Entities (Organization). +- MFA/TOTP (schema e endpoint ainda não implementados — `users.mfa_*` já existe no schema). - Adapters S3/SMTP. -- OpenAPI 3.1. - Extração do Design System a partir do tema DreamsERP (`packages/ui` ainda é placeholder). -- Nenhum commit git feito ainda — aguardando solicitação explícita do operador. +- CRUD de usuários/papéis (convite, reset de senha, gestão de `role_permissions`) — hoje só existe o super_admin seedado; não há endpoint para criar outros usuários ainda. +- Nota de dado de teste: a empresa "Handix Telecom LTDA" criada durante os testes usa um CNPJ de exemplo publicamente conhecido (`11.222.333/0001-81`), não o CNPJ real da Handix — substituir/remover antes de qualquer ambiente que não seja dev local. diff --git a/packages/config/package.json b/packages/config/package.json index 5c92952..15090ce 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -10,7 +10,11 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "echo 'no linter configured yet'" }, + "dependencies": { + "zod": "^3.24.1" + }, "devDependencies": { + "@types/node": "^22.10.5", "typescript": "^5.7.3" } } diff --git a/packages/config/src/env-schema.ts b/packages/config/src/env-schema.ts new file mode 100644 index 0000000..f1e84d5 --- /dev/null +++ b/packages/config/src/env-schema.ts @@ -0,0 +1,50 @@ +import { z } from "zod"; + +/** + * Fail fast on boot if a required secret/config value is missing — same + * philosophy as the legacy's JWT_SECRET/SEED_ADMIN_* checks (eden.md §5.6). + * Never fall back to an insecure default for a secret. + */ +export const envSchema = z.object({ + DATABASE_URL: z.string().min(1, "DATABASE_URL é obrigatório"), + EDEN_API_PORT: z.coerce.number().int().positive().default(8080), + + JWT_SIGNING_SECRET: z.string().min(32, "JWT_SIGNING_SECRET precisa ter ao menos 32 caracteres"), + SIGNATURE_OTP_SECRET: z.string().min(32).optional(), + EDEN_FIELD_ENCRYPTION_KEY: z + .string() + .min(32, "EDEN_FIELD_ENCRYPTION_KEY precisa ter ao menos 32 caracteres") + .optional(), + + EDEN_SUPERADMIN_EMAIL: z.string().email().optional(), + EDEN_SUPERADMIN_PASSWORD: z.string().min(8).optional(), + + SMTP_HOST: z.string().optional(), + SMTP_PORT: z.coerce.number().int().positive().default(465), + SMTP_USER: z.string().optional(), + SMTP_PASS: z.string().optional(), + SMTP_FROM: z.string().optional(), + + S3_ENDPOINT: z.string().optional(), + S3_REGION: z.string().default("us-east-1"), + S3_FORCE_PATH_STYLE: z + .string() + .default("true") + .transform((v) => v === "true"), + S3_ACCESS_KEY_ID: z.string().optional(), + S3_SECRET_ACCESS_KEY: z.string().optional(), + S3_BUCKET: z.string().optional(), + + PUBLIC_BASE_URL: z.string().optional(), +}); + +export type EdenEnv = z.infer; + +export function loadEnv(source: NodeJS.ProcessEnv = process.env): EdenEnv { + const result = envSchema.safeParse(source); + if (!result.success) { + const issues = result.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n"); + throw new Error(`Configuração de ambiente inválida:\n${issues}`); + } + return result.data; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 82e9741..0ef21c2 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -1,6 +1 @@ -/** - * Shared env-var schema/validation (fail fast on boot if a required var is - * missing — same philosophy as the legacy's JWT_SECRET/SEED_ADMIN_* checks, - * eden.md §5.6). Placeholder until apps/api's config module needs it. - */ -export const CONFIG_PACKAGE_VERSION = "0.1.0"; +export { envSchema, loadEnv, type EdenEnv } from "./env-schema.js"; diff --git a/packages/database/migrations/1788437478189_companies.cjs b/packages/database/migrations/1788437478189_companies.cjs new file mode 100644 index 0000000..5e803b6 --- /dev/null +++ b/packages/database/migrations/1788437478189_companies.cjs @@ -0,0 +1,54 @@ +/** + * Organization bounded context, first slice: companies (legal entities + * that issue contracts/documents) — Master Prompt §6.1. Shape ported + * faithfully from the legacy (eden.md §1.4), since this is validated, + * production-tested data. Warehouses/branches/cost centers land in a + * later migration once Inventory/Finance need them — Master Prompt §6.1 + * explicitly warns against leaving `companies` isolated forever, but + * that linkage is real work for those later phases, not invented now. + */ + +exports.shorthands = undefined; + +exports.up = (pgm) => { + pgm.sql(` + CREATE TABLE companies ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_by UUID REFERENCES users(id), + company_name TEXT NOT NULL, + trade_name TEXT, + cnpj TEXT, + state_registration TEXT, + municipal_registration TEXT, + address_zip TEXT, + address_street TEXT, + address_number TEXT, + address_complement TEXT, + address_neighborhood TEXT, + address_city TEXT, + address_state TEXT, + address_country TEXT NOT NULL DEFAULT 'Brasil', + phone TEXT, + email TEXT, + website TEXT, + legal_rep_name TEXT, + legal_rep_cpf TEXT, + legal_rep_role TEXT, + legal_rep_email TEXT, + is_active BOOLEAN NOT NULL DEFAULT true + ); + COMMENT ON TABLE companies IS + 'Empresa(s) emissora(s) de contrato/documento — eden.md §1.4. Leitura aberta a qualquer autenticado (não sensível), escrita restrita à permissão companies:manage.'; + + CREATE TRIGGER trg_companies_updated_at BEFORE UPDATE ON companies + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); + + CREATE INDEX idx_companies_active ON companies(is_active); + `); +}; + +exports.down = (pgm) => { + pgm.sql(`DROP TABLE IF EXISTS companies;`); +}; diff --git a/packages/database/package.json b/packages/database/package.json index d5585da..9bae6c0 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -17,6 +17,7 @@ "pg": "^8.13.1" }, "devDependencies": { + "@types/node": "^22.10.5", "@types/pg": "^8.11.10", "node-pg-migrate": "^7.9.1", "typescript": "^5.7.3" diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index 81b5d3a..9e7eacd 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1 +1,8 @@ export { query, withTransaction, closePool } from "./pool.js"; +export * as rolesRepo from "./repositories/roles.js"; +export * as usersRepo from "./repositories/users.js"; +export * as sessionsRepo from "./repositories/sessions.js"; +export * as auditRepo from "./repositories/audit.js"; +export * as companiesRepo from "./repositories/companies.js"; +export type * from "./repositories/types.js"; +export type { Company } from "./repositories/companies.js"; diff --git a/packages/database/src/repositories/audit.ts b/packages/database/src/repositories/audit.ts new file mode 100644 index 0000000..e3be393 --- /dev/null +++ b/packages/database/src/repositories/audit.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { query } from "../pool.js"; + +export interface AuditEventInput { + actorType: "user" | "system" | "service"; + actorId?: string | null; + action: string; + resourceType: string; + resourceId?: string | null; + metadata?: Record; + ipAddress?: string | null; +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + const obj = value as Record; + const keys = Object.keys(obj).sort(); + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`; +} + +/** + * Appends one event to the audit_log hash chain. Never call this inside a + * larger transaction that might roll back for unrelated reasons without + * considering that the chain must stay contiguous — a rolled-back event + * never happened, which is fine (nothing was recorded), but a *committed* + * event must never be skipped when computing previous_event_hash for the + * next one. See .claude/skills/eden-security/references/audit.md. + */ +export async function appendAuditEvent(input: AuditEventInput): Promise { + const { rows } = await query<{ event_hash: string }>( + "SELECT event_hash FROM audit_log ORDER BY seq DESC LIMIT 1", + ); + const previousEventHash = rows[0]?.event_hash ?? ""; + + const fields = { + actorType: input.actorType, + actorId: input.actorId ?? null, + action: input.action, + resourceType: input.resourceType, + resourceId: input.resourceId ?? null, + metadata: input.metadata ?? {}, + ipAddress: input.ipAddress ?? null, + }; + const eventHash = createHash("sha256") + .update(previousEventHash + canonicalJson(fields)) + .digest("hex"); + + await query( + `INSERT INTO audit_log + (actor_type, actor_id, action, resource_type, resource_id, metadata, ip_address, previous_event_hash, event_hash) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [ + input.actorType, + input.actorId ?? null, + input.action, + input.resourceType, + input.resourceId ?? null, + JSON.stringify(input.metadata ?? {}), + input.ipAddress ?? null, + previousEventHash || null, + eventHash, + ], + ); +} diff --git a/packages/database/src/repositories/companies.ts b/packages/database/src/repositories/companies.ts new file mode 100644 index 0000000..1262249 --- /dev/null +++ b/packages/database/src/repositories/companies.ts @@ -0,0 +1,163 @@ +import { query } from "../pool.js"; + +export interface Company { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + company_name: string; + trade_name: string | null; + cnpj: string | null; + state_registration: string | null; + municipal_registration: string | null; + address_zip: string | null; + address_street: string | null; + address_number: string | null; + address_complement: string | null; + address_neighborhood: string | null; + address_city: string | null; + address_state: string | null; + address_country: string; + phone: string | null; + email: string | null; + website: string | null; + legal_rep_name: string | null; + legal_rep_cpf: string | null; + legal_rep_role: string | null; + legal_rep_email: string | null; + is_active: boolean; +} + +export async function listCompanies(includeInactive = false): Promise { + const { rows } = await query( + includeInactive + ? "SELECT * FROM companies ORDER BY created_at ASC" + : "SELECT * FROM companies WHERE is_active = true ORDER BY created_at ASC", + ); + return rows; +} + +export async function getCompanyById(id: string): Promise { + const { rows } = await query("SELECT * FROM companies WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export interface CreateCompanyInput { + companyName: string; + tradeName?: string | null; + cnpj?: string | null; + stateRegistration?: string | null; + municipalRegistration?: string | null; + addressZip?: string | null; + addressStreet?: string | null; + addressNumber?: string | null; + addressComplement?: string | null; + addressNeighborhood?: string | null; + addressCity?: string | null; + addressState?: string | null; + addressCountry?: string; + phone?: string | null; + email?: string | null; + website?: string | null; + legalRepName?: string | null; + legalRepCpf?: string | null; + legalRepRole?: string | null; + legalRepEmail?: string | null; + createdBy?: string | null; +} + +export async function createCompany(input: CreateCompanyInput): Promise { + const { rows } = await query( + `INSERT INTO companies ( + company_name, trade_name, cnpj, state_registration, municipal_registration, + address_zip, address_street, address_number, address_complement, address_neighborhood, + address_city, address_state, address_country, phone, email, website, + legal_rep_name, legal_rep_cpf, legal_rep_role, legal_rep_email, created_by + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,COALESCE($13,'Brasil'),$14,$15,$16,$17,$18,$19,$20,$21) + RETURNING *`, + [ + input.companyName, + input.tradeName ?? null, + input.cnpj ?? null, + input.stateRegistration ?? null, + input.municipalRegistration ?? null, + input.addressZip ?? null, + input.addressStreet ?? null, + input.addressNumber ?? null, + input.addressComplement ?? null, + input.addressNeighborhood ?? null, + input.addressCity ?? null, + input.addressState ?? null, + input.addressCountry ?? null, + input.phone ?? null, + input.email ?? null, + input.website ?? null, + input.legalRepName ?? null, + input.legalRepCpf ?? null, + input.legalRepRole ?? null, + input.legalRepEmail ?? null, + input.createdBy ?? null, + ], + ); + const company = rows[0]; + if (!company) throw new Error("failed to create company"); + return company; +} + +export type UpdateCompanyInput = Partial> & { + isActive?: boolean; +}; + +export async function updateCompany(id: string, input: UpdateCompanyInput): Promise { + const { rows } = await query( + `UPDATE companies SET + company_name = COALESCE($2, company_name), + trade_name = COALESCE($3, trade_name), + cnpj = COALESCE($4, cnpj), + state_registration = COALESCE($5, state_registration), + municipal_registration = COALESCE($6, municipal_registration), + address_zip = COALESCE($7, address_zip), + address_street = COALESCE($8, address_street), + address_number = COALESCE($9, address_number), + address_complement = COALESCE($10, address_complement), + address_neighborhood = COALESCE($11, address_neighborhood), + address_city = COALESCE($12, address_city), + address_state = COALESCE($13, address_state), + address_country = COALESCE($14, address_country), + phone = COALESCE($15, phone), + email = COALESCE($16, email), + website = COALESCE($17, website), + legal_rep_name = COALESCE($18, legal_rep_name), + legal_rep_cpf = COALESCE($19, legal_rep_cpf), + legal_rep_role = COALESCE($20, legal_rep_role), + legal_rep_email = COALESCE($21, legal_rep_email), + is_active = COALESCE($22, is_active) + WHERE id = $1 + RETURNING *`, + [ + id, + input.companyName ?? null, + input.tradeName ?? null, + input.cnpj ?? null, + input.stateRegistration ?? null, + input.municipalRegistration ?? null, + input.addressZip ?? null, + input.addressStreet ?? null, + input.addressNumber ?? null, + input.addressComplement ?? null, + input.addressNeighborhood ?? null, + input.addressCity ?? null, + input.addressState ?? null, + input.addressCountry ?? null, + input.phone ?? null, + input.email ?? null, + input.website ?? null, + input.legalRepName ?? null, + input.legalRepCpf ?? null, + input.legalRepRole ?? null, + input.legalRepEmail ?? null, + input.isActive ?? null, + ], + ); + return rows[0] ?? null; +} diff --git a/packages/database/src/repositories/roles.ts b/packages/database/src/repositories/roles.ts new file mode 100644 index 0000000..1783828 --- /dev/null +++ b/packages/database/src/repositories/roles.ts @@ -0,0 +1,20 @@ +import { query } from "../pool.js"; +import type { Role, RolePermission } from "./types.js"; + +export async function getRoleByKey(key: string): Promise { + const { rows } = await query("SELECT * FROM roles WHERE key = $1", [key]); + return rows[0] ?? null; +} + +export async function listRoles(): Promise { + const { rows } = await query("SELECT * FROM roles ORDER BY weight ASC"); + return rows; +} + +export async function getRolePermissions(roleKey: string): Promise { + const { rows } = await query( + "SELECT * FROM role_permissions WHERE role_key = $1", + [roleKey], + ); + return rows; +} diff --git a/packages/database/src/repositories/sessions.ts b/packages/database/src/repositories/sessions.ts new file mode 100644 index 0000000..b085005 --- /dev/null +++ b/packages/database/src/repositories/sessions.ts @@ -0,0 +1,84 @@ +import { query } from "../pool.js"; +import type { Session } from "./types.js"; + +export interface CreateSessionInput { + userId: string; + applicationKey: "core" | "reseller" | "subscriber"; + refreshTokenHash: string; + expiresAt: Date; + ipAddress?: string | null; + userAgent?: string | null; + replacesSessionId?: string | null; +} + +export async function createSession(input: CreateSessionInput): Promise { + const { rows } = await query( + `INSERT INTO sessions (user_id, application_key, refresh_token_hash, expires_at, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + input.userId, + input.applicationKey, + input.refreshTokenHash, + input.expiresAt, + input.ipAddress ?? null, + input.userAgent ?? null, + ], + ); + const session = rows[0]; + if (!session) throw new Error("failed to create session"); + + if (input.replacesSessionId) { + await query("UPDATE sessions SET replaced_by_session_id = $1, revoked_at = now() WHERE id = $2", [ + session.id, + input.replacesSessionId, + ]); + } + + return session; +} + +export async function findActiveSessionByRefreshTokenHash(hash: string): Promise { + const { rows } = await query( + `SELECT * FROM sessions + WHERE refresh_token_hash = $1 AND revoked_at IS NULL AND expires_at > now()`, + [hash], + ); + return rows[0] ?? null; +} + +/** + * Finds a session by refresh token hash regardless of revoked/expired state — + * used for replay detection: a rotated (already-revoked) token being + * reused is a strong signal of a stolen refresh token (ADR-0003). + */ +export async function findSessionByRefreshTokenHashAnyState(hash: string): Promise { + const { rows } = await query("SELECT * FROM sessions WHERE refresh_token_hash = $1", [ + hash, + ]); + return rows[0] ?? null; +} + +export async function touchSession(id: string): Promise { + await query("UPDATE sessions SET last_used_at = now() WHERE id = $1", [id]); +} + +export async function revokeSession(id: string): Promise { + await query("UPDATE sessions SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL", [id]); +} + +/** Revokes every active session of a user — "encerrar todas as sessões" (Master Prompt §5.3). */ +export async function revokeAllUserSessions(userId: string): Promise { + await query("UPDATE sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL", [ + userId, + ]); +} + +export async function listActiveSessionsForUser(userId: string): Promise { + const { rows } = await query( + `SELECT * FROM sessions WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > now() + ORDER BY created_at DESC`, + [userId], + ); + return rows; +} diff --git a/packages/database/src/repositories/types.ts b/packages/database/src/repositories/types.ts new file mode 100644 index 0000000..21e75cd --- /dev/null +++ b/packages/database/src/repositories/types.ts @@ -0,0 +1,53 @@ +export interface Role { + key: string; + label: string; + is_system: boolean; + weight: number; + created_at: Date; + updated_at: Date; + created_by: string | null; +} + +export interface RolePermission { + id: string; + role_key: string; + resource: string; + action: "view" | "create" | "edit" | "delete" | "approve" | "export" | "manage"; + scope: "own" | "team" | "reseller" | "legal_entity" | "all"; +} + +export interface User { + id: string; + created_at: Date; + updated_at: Date; + created_by: string | null; + full_name: string | null; + email: string; + password_hash: string | null; + password_algo_version: number; + role_key: string; + is_active: boolean; + reset_token_hash: string | null; + reset_token_expires_at: Date | null; + mfa_enabled: boolean; + mfa_secret_encrypted: string | null; +} + +export interface Session { + id: string; + user_id: string; + application_key: "core" | "reseller" | "subscriber"; + refresh_token_hash: string; + created_at: Date; + last_used_at: Date | null; + expires_at: Date; + revoked_at: Date | null; + replaced_by_session_id: string | null; + ip_address: string | null; + user_agent: string | null; +} + +export interface Application { + key: "core" | "reseller" | "subscriber"; + label: string; +} diff --git a/packages/database/src/repositories/users.ts b/packages/database/src/repositories/users.ts new file mode 100644 index 0000000..bb3185d --- /dev/null +++ b/packages/database/src/repositories/users.ts @@ -0,0 +1,80 @@ +import { query } from "../pool.js"; +import type { User } from "./types.js"; + +export async function countUsers(): Promise { + const { rows } = await query<{ count: string }>("SELECT count(*) FROM users"); + return Number(rows[0]?.count ?? 0); +} + +export async function findUserByEmail(email: string): Promise { + const { rows } = await query( + "SELECT * FROM users WHERE lower(email) = lower($1) AND is_active = true", + [email], + ); + return rows[0] ?? null; +} + +export async function findUserById(id: string): Promise { + const { rows } = await query("SELECT * FROM users WHERE id = $1", [id]); + return rows[0] ?? null; +} + +export interface CreateUserInput { + email: string; + passwordHash: string; + passwordAlgoVersion: number; + roleKey: string; + fullName?: string; + createdBy?: string | null; +} + +export async function createUser(input: CreateUserInput): Promise { + const { rows } = await query( + `INSERT INTO users (email, password_hash, password_algo_version, role_key, full_name, created_by) + VALUES (lower($1), $2, $3, $4, $5, $6) + RETURNING *`, + [ + input.email, + input.passwordHash, + input.passwordAlgoVersion, + input.roleKey, + input.fullName ?? null, + input.createdBy ?? null, + ], + ); + const user = rows[0]; + if (!user) throw new Error("failed to create user"); + return user; +} + +export async function updateUserPassword( + userId: string, + passwordHash: string, + passwordAlgoVersion: number, +): Promise { + await query("UPDATE users SET password_hash = $1, password_algo_version = $2 WHERE id = $3", [ + passwordHash, + passwordAlgoVersion, + userId, + ]); +} + +export async function grantUserApplication( + userId: string, + applicationKey: "core" | "reseller" | "subscriber", +): Promise { + await query( + `INSERT INTO user_applications (user_id, application_key) + VALUES ($1, $2) + ON CONFLICT (user_id, application_key) DO NOTHING`, + [userId, applicationKey], + ); +} + +export async function getUserApplications(userId: string): Promise { + const { rows } = await query<{ application_key: string }>( + "SELECT application_key FROM user_applications WHERE user_id = $1", + [userId], + ); + return rows.map((r) => r.application_key); +} diff --git a/packages/domain-shared/src/index.ts b/packages/domain-shared/src/index.ts index 1d722dc..37c26e4 100644 --- a/packages/domain-shared/src/index.ts +++ b/packages/domain-shared/src/index.ts @@ -1,7 +1,2 @@ -/** - * Domain types/constants shared across bounded contexts (e.g. the - * resource/action/scope permission vocabulary from - * docs/adr/0004-permission-model.md). Placeholder until a second - * bounded context needs to share a type with the first. - */ export const DOMAIN_SHARED_PACKAGE_VERSION = "0.1.0"; +export { onlyDigits, isValidCPF, isValidCNPJ } from "./validators/br-documents.js"; diff --git a/packages/domain-shared/src/validators/br-documents.ts b/packages/domain-shared/src/validators/br-documents.ts new file mode 100644 index 0000000..a32fecd --- /dev/null +++ b/packages/domain-shared/src/validators/br-documents.ts @@ -0,0 +1,49 @@ +/** + * CPF/CNPJ format validation (digit-check only — never confirms a document + * actually exists or belongs to whoever claims it). Ported from the + * standard Brazilian algorithm, matching the legacy's isValidCPF/ + * isValidCNPJ (eden.md §1 companies, §3 client registration). + */ +export function onlyDigits(value: string): string { + return value.replace(/\D/g, ""); +} + +export function isValidCPF(value: string): boolean { + const cpf = onlyDigits(value); + if (cpf.length !== 11 || /^(\d)\1{10}$/.test(cpf)) return false; + + const digits = cpf.split("").map(Number); + const checkDigit = (length: number): number => { + let sum = 0; + for (let i = 0; i < length; i++) { + sum += digits[i]! * (length + 1 - i); + } + const remainder = (sum * 10) % 11; + return remainder === 10 ? 0 : remainder; + }; + + return checkDigit(9) === digits[9] && checkDigit(10) === digits[10]; +} + +export function isValidCNPJ(value: string): boolean { + const cnpj = onlyDigits(value); + if (cnpj.length !== 14 || /^(\d)\1{13}$/.test(cnpj)) return false; + + const digits = cnpj.split("").map(Number); + // Standard CNPJ weight sequences — the second is the first with a + // leading 6, not a generic slice (easy to get subtly wrong otherwise). + const WEIGHTS_12 = [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + const WEIGHTS_13 = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]; + const checkDigit = (length: number, weights: number[]): number => { + let sum = 0; + for (let i = 0; i < length; i++) { + sum += digits[i]! * weights[i]!; + } + const remainder = sum % 11; + return remainder < 2 ? 0 : 11 - remainder; + }; + + return ( + checkDigit(12, WEIGHTS_12) === digits[12] && checkDigit(13, WEIGHTS_13) === digits[13] + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ac0ae..688e693 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,24 +17,45 @@ importers: apps/api: dependencies: + '@eden/config': + specifier: workspace:* + version: link:../../packages/config '@eden/database': specifier: workspace:* version: link:../../packages/database + '@eden/domain-shared': + specifier: workspace:* + version: link:../../packages/domain-shared '@nestjs/common': specifier: ^10.4.15 - version: 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) '@nestjs/core': specifier: ^10.4.15 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': specifier: ^10.4.15 - version: 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) + '@nestjs/swagger': + specifier: ^8.1.0 + version: 8.1.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/terminus': specifier: ^10.2.3 - version: 10.3.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + version: 10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + argon2: + specifier: ^0.41.1 + version: 0.41.1 + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 express: specifier: ^4.21.2 version: 4.22.2(supports-color@7.2.0) + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.3 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -45,6 +66,9 @@ importers: '@types/express': specifier: ^5.0.0 version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.10 '@types/node': specifier: ^22.10.5 version: 22.20.1 @@ -180,7 +204,14 @@ importers: version: 5.9.3 packages/config: + dependencies: + zod: + specifier: ^3.24.1 + version: 3.25.76 devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.20.1 typescript: specifier: ^5.7.3 version: 5.9.3 @@ -197,6 +228,9 @@ importers: specifier: ^8.13.1 version: 8.23.0 devDependencies: + '@types/node': + specifier: ^22.10.5 + version: 22.20.1 '@types/pg': specifier: ^8.11.10 version: 8.23.1 @@ -672,6 +706,9 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@microsoft/tsdoc@0.15.1': + resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} engines: {node: ^22.20 || ^24.12 || >=25} @@ -709,12 +746,42 @@ packages: '@nestjs/websockets': optional: true + '@nestjs/mapped-types@2.0.6': + resolution: {integrity: sha512-84ze+CPfp1OWdpRi1/lOu59hOhTz38eVzJvRKrg9ykRFwDz+XleKfMsG0gUqNZYFa6v53XYzeD+xItt8uDW7NQ==} + peerDependencies: + '@nestjs/common': ^8.0.0 || ^9.0.0 || ^10.0.0 + class-transformer: ^0.4.0 || ^0.5.0 + class-validator: ^0.13.0 || ^0.14.0 + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/platform-express@10.4.22': resolution: {integrity: sha512-ySSq7Py/DFozzZdNDH67m/vHoeVdphDniWBnl6q5QVoXldDdrZIHLXLRMPayTDh5A95nt7jjJzmD4qpTbNQ6tA==} peerDependencies: '@nestjs/common': ^10.0.0 '@nestjs/core': ^10.0.0 + '@nestjs/swagger@8.1.1': + resolution: {integrity: sha512-5Mda7H1DKnhKtlsb0C7PYshcvILv8UFyUotHzxmWh0G65Z21R3LZH/J8wmpnlzL4bmXIfr42YwbEwRxgzpJ5sQ==} + peerDependencies: + '@fastify/static': ^6.0.0 || ^7.0.0 + '@nestjs/common': ^9.0.0 || ^10.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 + class-transformer: '*' + class-validator: '*' + reflect-metadata: ^0.1.12 || ^0.2.0 + peerDependenciesMeta: + '@fastify/static': + optional: true + class-transformer: + optional: true + class-validator: + optional: true + '@nestjs/terminus@10.3.0': resolution: {integrity: sha512-vOJGCwt1OgrFuuxWQwPoaHqy9m9CfIk2qMUX2mosZLK5dFVJSEjHXrklkh3/Fw9PiUnfzvYFfiAdJRzUaxx+5Q==} peerDependencies: @@ -780,6 +847,10 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true + '@phc/format@1.0.0': + resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} + engines: {node: '>=10'} + '@rolldown/pluginutils@1.0.0-beta.27': resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} @@ -921,6 +992,9 @@ packages: cpu: [x64] os: [win32] + '@scarf/scarf@1.4.0': + resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==} + '@tokenizer/inflate@0.2.7': resolution: {integrity: sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg==} engines: {node: '>=18'} @@ -988,6 +1062,12 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@22.20.1': resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} @@ -1017,6 +1097,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/validator@13.15.10': + resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@vitejs/plugin-react@4.7.0': resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} engines: {node: ^14.18.0 || >=16.0.0} @@ -1051,6 +1134,13 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argon2@0.41.1: + resolution: {integrity: sha512-dqCW8kJXke8Ik+McUcMDltrbuAWETPyU6iq+4AhxqKphWi7pChB/Zgd/Tp/o8xRLbg8ksMj46F/vph9wnxpTzQ==} + engines: {node: '>=16.17.0'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + array-flatten@1.1.1: resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} @@ -1099,6 +1189,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1141,6 +1234,12 @@ packages: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + class-transformer@0.5.1: + resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} + + class-validator@0.14.4: + resolution: {integrity: sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==} + cli-boxes@2.2.1: resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} engines: {node: '>=6'} @@ -1236,6 +1335,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} @@ -1453,6 +1555,10 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -1463,6 +1569,19 @@ packages: engines: {node: '>=6'} hasBin: true + jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + + jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + + jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + + libphonenumber-js@1.13.12: + resolution: {integrity: sha512-uLVeV1c9OTk6qkdqnj+mpMD+ZdnZ0szVyWu58HwMmpwkHA1gCEkyjd3veZQXDnuw9KEwSRjcc9B1pS9XKIN1fA==} + lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -1470,6 +1589,30 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + + lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + + lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + + lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + + lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + + lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + + lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + + lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -1554,6 +1697,10 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} + node-addon-api@8.9.2: + resolution: {integrity: sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==} + engines: {node: ^18 || ^20 || >= 21} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -1563,6 +1710,10 @@ packages: encoding: optional: true + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + node-pg-migrate@7.9.1: resolution: {integrity: sha512-6z4OSN27ye8aYdX9ZU7NN2PTI5pOp34hTr+22Ej12djIYECq++gT7LPLZVOQXEeVCBOZQLqf87kC3Y36G434OQ==} engines: {node: '>=18.19.0'} @@ -1821,6 +1972,11 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -1904,6 +2060,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swagger-ui-dist@5.18.2: + resolution: {integrity: sha512-J+y4mCw/zXh1FOj5wGJvnAajq6XgHOyywsa9yITmwxIlJbMqITq3gYRZHaeqLVH/eV/HOPphE6NjF+nbSNC5Zw==} + tailwindcss@3.4.19: resolution: {integrity: sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==} engines: {node: '>=14.0.0'} @@ -1994,6 +2153,10 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} + validator@13.15.35: + resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} + engines: {node: '>= 0.10'} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -2076,6 +2239,9 @@ packages: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + snapshots: '@alloc/quick-lru@5.3.0': {} @@ -2373,10 +2539,12 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@microsoft/tsdoc@0.15.1': {} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': optional: true - '@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': + '@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0)': dependencies: file-type: 20.4.1(supports-color@7.2.0) iterare: 1.2.1 @@ -2384,12 +2552,15 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 transitivePeerDependencies: - supports-color - '@nestjs/core@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) '@nuxtjs/opencollective': 0.3.2 fast-safe-stringify: 2.1.1 iterare: 1.2.1 @@ -2399,14 +2570,22 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) + '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0) transitivePeerDependencies: - encoding - '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0)': + '@nestjs/mapped-types@2.0.6(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(supports-color@7.2.0)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) body-parser: 1.20.4(supports-color@7.2.0) cors: 2.8.5 express: 4.22.1(supports-color@7.2.0) @@ -2415,10 +2594,25 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/terminus@10.3.0(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + '@nestjs/swagger@8.1.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) - '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@microsoft/tsdoc': 0.15.1 + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.0.6(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + js-yaml: 4.1.0 + lodash: 4.17.21 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + swagger-ui-dist: 5.18.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + + '@nestjs/terminus@10.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@7.2.0))(@nestjs/platform-express@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2) boxen: 5.1.2 check-disk-space: 3.4.0 reflect-metadata: 0.2.2 @@ -2444,6 +2638,8 @@ snapshots: transitivePeerDependencies: - encoding + '@phc/format@1.0.0': {} + '@rolldown/pluginutils@1.0.0-beta.27': {} '@rollup/rollup-android-arm-eabi@4.63.1': @@ -2521,6 +2717,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.1': optional: true + '@scarf/scarf@1.4.0': {} + '@tokenizer/inflate@0.2.7(supports-color@7.2.0)': dependencies: debug: 4.4.3(supports-color@7.2.0) @@ -2596,6 +2794,13 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/jsonwebtoken@9.0.10': + dependencies: + '@types/ms': 2.1.0 + '@types/node': 22.20.1 + + '@types/ms@2.1.0': {} + '@types/node@22.20.1': dependencies: undici-types: 6.21.0 @@ -2630,6 +2835,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 22.20.1 + '@types/validator@13.15.10': {} + '@vitejs/plugin-react@4.7.0(supports-color@7.2.0)(vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13))': dependencies: '@babel/core': 7.29.7(supports-color@7.2.0) @@ -2668,6 +2875,14 @@ snapshots: arg@5.0.2: {} + argon2@0.41.1: + dependencies: + '@phc/format': 1.0.0 + node-addon-api: 8.9.2 + node-gyp-build: 4.8.4 + + argparse@2.0.1: {} + array-flatten@1.1.1: {} autoprefixer@10.5.4(postcss@8.5.26): @@ -2746,6 +2961,8 @@ snapshots: node-releases: 2.0.54 update-browserslist-db: 1.3.2(browserslist@4.28.8) + buffer-equal-constant-time@1.0.1: {} + buffer-from@1.1.2: {} busboy@1.6.0: @@ -2789,6 +3006,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + class-transformer@0.5.1: {} + + class-validator@0.14.4: + dependencies: + '@types/validator': 13.15.10 + libphonenumber-js: 1.13.12 + validator: 13.15.35 + cli-boxes@2.2.1: {} cliui@8.0.1: @@ -2867,6 +3092,10 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ecdsa-sig-formatter@1.0.11: + dependencies: + safe-buffer: 5.2.1 + ee-first@1.1.1: {} electron-to-chromium@1.5.420: {} @@ -3177,14 +3406,60 @@ snapshots: js-tokens@4.0.0: {} + js-yaml@4.1.0: + dependencies: + argparse: 2.0.1 + jsesc@3.1.0: {} json5@2.2.3: {} + jsonwebtoken@9.0.3: + dependencies: + jws: 4.0.1 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.8.5 + + jwa@2.0.1: + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + + jws@4.0.1: + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + + libphonenumber-js@1.13.12: {} + lilconfig@3.1.3: {} lines-and-columns@1.2.4: {} + lodash.includes@4.3.0: {} + + lodash.isboolean@3.0.3: {} + + lodash.isinteger@4.0.4: {} + + lodash.isnumber@3.0.3: {} + + lodash.isplainobject@4.0.6: {} + + lodash.isstring@4.0.1: {} + + lodash.once@4.1.1: {} + + lodash@4.17.21: {} + loose-envify@1.4.0: dependencies: js-tokens: 4.0.0 @@ -3254,10 +3529,14 @@ snapshots: negotiator@0.6.3: {} + node-addon-api@8.9.2: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 + node-gyp-build@4.8.4: {} + node-pg-migrate@7.9.1(@types/pg@8.23.1)(pg@8.23.0): dependencies: glob: 11.0.3 @@ -3500,6 +3779,8 @@ snapshots: semver@6.3.1: {} + semver@7.8.5: {} + send@0.19.2(supports-color@7.2.0): dependencies: debug: 2.6.9(supports-color@7.2.0) @@ -3607,6 +3888,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swagger-ui-dist@5.18.2: + dependencies: + '@scarf/scarf': 1.4.0 + tailwindcss@3.4.19(tsx@4.23.13): dependencies: '@alloc/quick-lru': 5.3.0 @@ -3712,6 +3997,8 @@ snapshots: utils-merge@1.0.1: {} + validator@13.15.35: {} + vary@1.1.2: {} vite@6.4.3(@types/node@22.20.1)(jiti@1.21.7)(tsx@4.23.13): @@ -3766,3 +4053,5 @@ snapshots: string-width: 4.2.3 y18n: 5.0.8 yargs-parser: 21.1.1 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b1beebc..08faa3c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,4 +3,6 @@ packages: - "packages/*" allowBuilds: '@nestjs/core': true + '@scarf/scarf': false + argon2: true esbuild: true