From 4f637ede8c79a83439c3f94626d9f43f9c36021a Mon Sep 17 00:00:00 2001 From: defiQUG Date: Fri, 23 Jan 2026 18:48:59 -0800 Subject: [PATCH] Implement Phase 1a, 1b, 1c: Database, Auth, and API Endpoints Phase 1a - Database Setup: - Add PostgreSQL connection pooling with pg client - Create 8 SQL migrations for all database schemas - Implement migration execution system with tracking - Add environment configuration for database and JWT settings Phase 1b - Authentication & Authorization: - Implement password hashing with bcrypt - Create JWT token generation (access + refresh tokens) - Implement RBAC with 5 roles (Admin, Manager, Analyst, Auditor, Viewer) - Create auth middleware for authentication and authorization - Add auth routes (login, register, refresh, logout, profile) Phase 1c - API Endpoints (Full CRUD): - Transaction endpoints with evaluation and batch processing - Account management (treasury and subledger accounts) - User management (admin-only) - FX contract management - Compliance endpoints (rules, results, thresholds) - Reporting endpoints (summary, compliance, audit logs) - Health check endpoints with database status Phase 1d - Data Seeding: - Create database seeding system with roles, permissions, users - Add sample data (treasury accounts, FX contracts) - Implement admin user creation from environment variables All endpoints protected with authentication and role-based access control. --- apps/api/package.json | 9 +- apps/api/src/auth/jwt.ts | 140 +++++ apps/api/src/auth/password.ts | 63 ++ apps/api/src/auth/roles.ts | 159 +++++ apps/api/src/db/connection.ts | 131 ++++ apps/api/src/db/migrate.ts | 129 ++++ .../migrations/001_create_users_and_roles.sql | 41 ++ .../db/migrations/002_create_permissions.sql | 21 + .../db/migrations/003_create_transactions.sql | 56 ++ .../004_create_accounts_and_postings.sql | 61 ++ .../db/migrations/005_create_fx_contracts.sql | 35 ++ .../db/migrations/006_create_regulatory.sql | 56 ++ .../db/migrations/007_create_audit_logs.sql | 42 ++ .../migrations/008_create_refresh_tokens.sql | 16 + apps/api/src/db/seed.ts | 228 +++++++ apps/api/src/health.ts | 36 +- apps/api/src/index.ts | 88 ++- apps/api/src/middleware/auth.ts | 198 +++++++ apps/api/src/routes/accounts.ts | 263 +++++++++ apps/api/src/routes/auth.ts | 326 ++++++++++ apps/api/src/routes/compliance.ts | 84 +++ apps/api/src/routes/fx-contracts.ts | 129 ++++ apps/api/src/routes/reports.ts | 84 +++ apps/api/src/routes/transactions.ts | 347 +++++++++++ apps/api/src/routes/users.ts | 113 ++++ apps/web/src/components/UserMenu.tsx | 88 +++ packages/utils/src/config.ts | 13 + pnpm-lock.yaml | 557 +++++++++++++++++- 28 files changed, 3475 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/auth/jwt.ts create mode 100644 apps/api/src/auth/password.ts create mode 100644 apps/api/src/auth/roles.ts create mode 100644 apps/api/src/db/connection.ts create mode 100644 apps/api/src/db/migrate.ts create mode 100644 apps/api/src/db/migrations/001_create_users_and_roles.sql create mode 100644 apps/api/src/db/migrations/002_create_permissions.sql create mode 100644 apps/api/src/db/migrations/003_create_transactions.sql create mode 100644 apps/api/src/db/migrations/004_create_accounts_and_postings.sql create mode 100644 apps/api/src/db/migrations/005_create_fx_contracts.sql create mode 100644 apps/api/src/db/migrations/006_create_regulatory.sql create mode 100644 apps/api/src/db/migrations/007_create_audit_logs.sql create mode 100644 apps/api/src/db/migrations/008_create_refresh_tokens.sql create mode 100644 apps/api/src/db/seed.ts create mode 100644 apps/api/src/middleware/auth.ts create mode 100644 apps/api/src/routes/accounts.ts create mode 100644 apps/api/src/routes/auth.ts create mode 100644 apps/api/src/routes/compliance.ts create mode 100644 apps/api/src/routes/fx-contracts.ts create mode 100644 apps/api/src/routes/reports.ts create mode 100644 apps/api/src/routes/transactions.ts create mode 100644 apps/api/src/routes/users.ts create mode 100644 apps/web/src/components/UserMenu.tsx diff --git a/apps/api/package.json b/apps/api/package.json index 6ca7fe4..c5bed57 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -12,13 +12,20 @@ "@brazil-swift-ops/types": "workspace:*", "@brazil-swift-ops/utils": "workspace:*", "@brazil-swift-ops/rules-engine": "workspace:*", + "bcrypt": "^5.1.1", "cors": "^2.8.5", - "express": "^4.18.2" + "dotenv": "^16.3.1", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.3", + "pg": "^8.11.3" }, "devDependencies": { + "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", "@types/node": "^20.10.0", + "@types/pg": "^8.11.5", "tsx": "^4.7.0", "typescript": "^5.3.3" } diff --git a/apps/api/src/auth/jwt.ts b/apps/api/src/auth/jwt.ts new file mode 100644 index 0000000..d0e4ede --- /dev/null +++ b/apps/api/src/auth/jwt.ts @@ -0,0 +1,140 @@ +/** + * JWT Token Management + * Generate, verify, and manage JWT tokens + */ + +import * as jwt from 'jsonwebtoken'; +import { getConfig } from '@brazil-swift-ops/utils'; +import type { SignOptions } from 'jsonwebtoken'; + +export interface TokenPayload { + userId: number; + email: string; + roles: number[]; + permissions: string[]; +} + +/** + * Generate access token (short-lived) + */ +export function generateAccessToken(payload: TokenPayload): string { + const config = getConfig(); + + if (!config.jwtSecret) { + throw new Error('JWT_SECRET not configured'); + } + + const expiresIn = `${config.jwtExpiryMinutes || 15}m`; + + return jwt.sign(payload, config.jwtSecret, { + expiresIn, + algorithm: 'HS256', + } as SignOptions); +} + +/** + * Generate refresh token (long-lived) + */ +export function generateRefreshToken(userId: number, email: string): string { + const config = getConfig(); + + if (!config.jwtRefreshSecret) { + throw new Error('JWT_REFRESH_SECRET not configured'); + } + + const expiresIn = `${config.jwtRefreshExpiryDays || 7}d`; + + return jwt.sign( + { + userId, + email, + type: 'refresh', + }, + config.jwtRefreshSecret, + { + expiresIn, + algorithm: 'HS256', + } as SignOptions + ); +} + +/** + * Verify access token + */ +export function verifyAccessToken(token: string): TokenPayload | null { + const config = getConfig(); + + if (!config.jwtSecret) { + throw new Error('JWT_SECRET not configured'); + } + + try { + const decoded = jwt.verify(token, config.jwtSecret, { + algorithms: ['HS256'], + }) as TokenPayload; + return decoded; + } catch (error) { + console.error('Token verification failed:', error); + return null; + } +} + +/** + * Verify refresh token + */ +export function verifyRefreshToken( + token: string +): { userId: number; email: string } | null { + const config = getConfig(); + + if (!config.jwtRefreshSecret) { + throw new Error('JWT_REFRESH_SECRET not configured'); + } + + try { + const decoded = jwt.verify(token, config.jwtRefreshSecret, { + algorithms: ['HS256'], + }) as { userId: number; email: string; type: string }; + + if (decoded.type !== 'refresh') { + return null; + } + + return { + userId: decoded.userId, + email: decoded.email, + }; + } catch (error) { + console.error('Refresh token verification failed:', error); + return null; + } +} + +/** + * Extract token from Authorization header + */ +export function extractTokenFromHeader( + authHeader?: string +): string | null { + if (!authHeader) return null; + + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + return null; + } + + return parts[1]; +} + +/** + * Get token expiration time + */ +export function getTokenExpirationTime(token: string): Date | null { + try { + const decoded = jwt.decode(token) as any; + if (!decoded || !decoded.exp) return null; + return new Date(decoded.exp * 1000); + } catch { + return null; + } +} diff --git a/apps/api/src/auth/password.ts b/apps/api/src/auth/password.ts new file mode 100644 index 0000000..35f0b99 --- /dev/null +++ b/apps/api/src/auth/password.ts @@ -0,0 +1,63 @@ +/** + * Password Management + * Hash and verify passwords using bcrypt + */ + +import * as bcrypt from 'bcrypt'; + +const SALT_ROUNDS = 10; + +/** + * Hash a password + */ +export async function hashPassword(password: string): Promise { + if (!password || password.length < 6) { + throw new Error('Password must be at least 6 characters long'); + } + + return bcrypt.hash(password, SALT_ROUNDS); +} + +/** + * Verify a password against a hash + */ +export async function verifyPassword( + password: string, + hash: string +): Promise { + try { + return await bcrypt.compare(password, hash); + } catch (error) { + return false; + } +} + +/** + * Validate password strength + */ +export function validatePasswordStrength( + password: string +): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!password || password.length < 6) { + errors.push('Password must be at least 6 characters'); + } + + if (!/[A-Z]/.test(password)) { + errors.push('Password must contain at least one uppercase letter'); + } + + if (!/[a-z]/.test(password)) { + errors.push('Password must contain at least one lowercase letter'); + } + + if (!/[0-9]/.test(password)) { + errors.push('Password must contain at least one number'); + } + + return { + valid: errors.length === 0, + errors, + }; +} diff --git a/apps/api/src/auth/roles.ts b/apps/api/src/auth/roles.ts new file mode 100644 index 0000000..e4d600c --- /dev/null +++ b/apps/api/src/auth/roles.ts @@ -0,0 +1,159 @@ +/** + * Role and Permission Definitions + * Defines RBAC roles and their permissions + */ + +export enum Role { + ADMIN = 'Admin', + MANAGER = 'Manager', + ANALYST = 'Analyst', + AUDITOR = 'Auditor', + VIEWER = 'Viewer', +} + +export const ROLE_DESCRIPTIONS: Record = { + [Role.ADMIN]: 'Full access to all features and user management', + [Role.MANAGER]: 'Can create, update, and manage transactions and accounts', + [Role.ANALYST]: 'Can create and view transactions, generate reports', + [Role.AUDITOR]: 'Read-only access to all data and audit logs', + [Role.VIEWER]: 'Read-only access to dashboard and summary reports', +}; + +export enum Permission { + // User management + USER_CREATE = 'user:create', + USER_READ = 'user:read', + USER_UPDATE = 'user:update', + USER_DELETE = 'user:delete', + + // Transaction management + TRANSACTION_CREATE = 'transaction:create', + TRANSACTION_READ = 'transaction:read', + TRANSACTION_UPDATE = 'transaction:update', + TRANSACTION_DELETE = 'transaction:delete', + + // Account management + ACCOUNT_CREATE = 'account:create', + ACCOUNT_READ = 'account:read', + ACCOUNT_UPDATE = 'account:update', + ACCOUNT_DELETE = 'account:delete', + + // FX Contracts + FX_CONTRACT_CREATE = 'fx_contract:create', + FX_CONTRACT_READ = 'fx_contract:read', + FX_CONTRACT_UPDATE = 'fx_contract:update', + FX_CONTRACT_DELETE = 'fx_contract:delete', + + // Compliance & Regulatory + COMPLIANCE_READ = 'compliance:read', + COMPLIANCE_UPDATE = 'compliance:update', + + // Reports & Audit + REPORT_READ = 'report:read', + REPORT_CREATE = 'report:create', + AUDIT_READ = 'audit:read', + + // System + SYSTEM_ADMIN = 'system:admin', +} + +export const ROLE_PERMISSIONS: Record = { + [Role.ADMIN]: [ + // Full access to everything + Permission.USER_CREATE, + Permission.USER_READ, + Permission.USER_UPDATE, + Permission.USER_DELETE, + Permission.TRANSACTION_CREATE, + Permission.TRANSACTION_READ, + Permission.TRANSACTION_UPDATE, + Permission.TRANSACTION_DELETE, + Permission.ACCOUNT_CREATE, + Permission.ACCOUNT_READ, + Permission.ACCOUNT_UPDATE, + Permission.ACCOUNT_DELETE, + Permission.FX_CONTRACT_CREATE, + Permission.FX_CONTRACT_READ, + Permission.FX_CONTRACT_UPDATE, + Permission.FX_CONTRACT_DELETE, + Permission.COMPLIANCE_READ, + Permission.COMPLIANCE_UPDATE, + Permission.REPORT_READ, + Permission.REPORT_CREATE, + Permission.AUDIT_READ, + Permission.SYSTEM_ADMIN, + ], + + [Role.MANAGER]: [ + Permission.TRANSACTION_CREATE, + Permission.TRANSACTION_READ, + Permission.TRANSACTION_UPDATE, + Permission.ACCOUNT_CREATE, + Permission.ACCOUNT_READ, + Permission.ACCOUNT_UPDATE, + Permission.FX_CONTRACT_CREATE, + Permission.FX_CONTRACT_READ, + Permission.FX_CONTRACT_UPDATE, + Permission.COMPLIANCE_READ, + Permission.REPORT_READ, + Permission.REPORT_CREATE, + ], + + [Role.ANALYST]: [ + Permission.TRANSACTION_CREATE, + Permission.TRANSACTION_READ, + Permission.TRANSACTION_UPDATE, + Permission.ACCOUNT_READ, + Permission.FX_CONTRACT_READ, + Permission.COMPLIANCE_READ, + Permission.REPORT_READ, + Permission.REPORT_CREATE, + ], + + [Role.AUDITOR]: [ + Permission.TRANSACTION_READ, + Permission.ACCOUNT_READ, + Permission.FX_CONTRACT_READ, + Permission.COMPLIANCE_READ, + Permission.REPORT_READ, + Permission.AUDIT_READ, + ], + + [Role.VIEWER]: [ + Permission.TRANSACTION_READ, + Permission.REPORT_READ, + ], +}; + +/** + * Check if a role has a specific permission + */ +export function hasPermission(role: Role, permission: Permission): boolean { + return ROLE_PERMISSIONS[role]?.includes(permission) ?? false; +} + +/** + * Check if any of the given roles have a specific permission + */ +export function hasPermissionAny(roles: Role[], permission: Permission): boolean { + return roles.some((role) => hasPermission(role, permission)); +} + +/** + * Check if all of the given roles have a specific permission + */ +export function hasPermissionAll(roles: Role[], permission: Permission): boolean { + return roles.every((role) => hasPermission(role, permission)); +} + +/** + * Get all permissions for given roles + */ +export function getPermissionsForRoles(roles: Role[]): Permission[] { + const permissionSet = new Set(); + for (const role of roles) { + const rolePermissions = ROLE_PERMISSIONS[role] || []; + rolePermissions.forEach((p) => permissionSet.add(p)); + } + return Array.from(permissionSet); +} diff --git a/apps/api/src/db/connection.ts b/apps/api/src/db/connection.ts new file mode 100644 index 0000000..006fefc --- /dev/null +++ b/apps/api/src/db/connection.ts @@ -0,0 +1,131 @@ +/** + * PostgreSQL Database Connection Management + * Handles connection pooling, health checks, and graceful shutdown + */ + +import { Pool, PoolClient } from 'pg'; +import { getConfig } from '@brazil-swift-ops/utils'; + +let pool: Pool | null = null; + +/** + * Initialize database connection pool + */ +export async function initializeDatabase(): Promise { + if (pool) { + return pool; + } + + const config = getConfig(); + + if (!config.databaseUrl) { + throw new Error('DATABASE_URL environment variable is not set'); + } + + pool = new Pool({ + connectionString: config.databaseUrl, + max: config.databasePoolSize || 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 2000, + }); + + // Handle pool errors + pool.on('error', (error) => { + console.error('Unexpected error on idle client', error); + }); + + // Test connection + try { + const client = await pool.connect(); + console.log('✓ Database connection established'); + client.release(); + } catch (error) { + console.error('✗ Failed to connect to database:', error); + throw error; + } + + return pool; +} + +/** + * Get database pool instance + */ +export function getPool(): Pool { + if (!pool) { + throw new Error('Database pool not initialized. Call initializeDatabase() first.'); + } + return pool; +} + +/** + * Get database client + */ +export async function getClient(): Promise { + const dbPool = getPool(); + return dbPool.connect(); +} + +/** + * Execute query with automatic client management + */ +export async function query( + text: string, + values?: any[] +): Promise<{ rows: T[]; rowCount: number }> { + const dbPool = getPool(); + const result = await dbPool.query(text, values); + return { + rows: result.rows as T[], + rowCount: result.rowCount || 0, + }; +} + +/** + * Check database connection health + */ +export async function checkDatabaseHealth(): Promise { + try { + await query('SELECT 1'); + return true; + } catch (error) { + console.error('Database health check failed:', error); + return false; + } +} + +/** + * Close database connection pool + */ +export async function closeDatabase(): Promise { + if (pool) { + await pool.end(); + pool = null; + console.log('Database connection pool closed'); + } +} + +/** + * Get database status information + */ +export async function getDatabaseStatus(): Promise<{ + connected: boolean; + poolSize: number; + idleClients: number; + waitingClients: number; +}> { + if (!pool) { + return { + connected: false, + poolSize: 0, + idleClients: 0, + waitingClients: 0, + }; + } + + return { + connected: true, + poolSize: pool.totalCount, + idleClients: pool.idleCount, + waitingClients: pool.waitingCount, + }; +} diff --git a/apps/api/src/db/migrate.ts b/apps/api/src/db/migrate.ts new file mode 100644 index 0000000..053ba33 --- /dev/null +++ b/apps/api/src/db/migrate.ts @@ -0,0 +1,129 @@ +/** + * Database Migration System + * Handles reading and executing SQL migrations + */ + +import { readdir, readFile } from 'fs/promises'; +import { join } from 'path'; +import { query } from './connection'; + +/** + * Run all pending migrations + */ +export async function runMigrations(): Promise { + console.log('Starting database migrations...'); + + try { + // Ensure schema_migrations table exists + await query(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + `); + + // Get all migration files + const migrationsDir = join(__dirname, 'migrations'); + const files = await readdir(migrationsDir); + const migrationFiles = files + .filter((f) => f.endsWith('.sql')) + .sort(); + + if (migrationFiles.length === 0) { + console.log('No migration files found'); + return; + } + + // Get executed migrations + const executedResult = await query<{ version: number }>( + 'SELECT version FROM schema_migrations ORDER BY version' + ); + const executedVersions = new Set(executedResult.rows.map((r) => r.version)); + + // Execute pending migrations + let executedCount = 0; + for (const file of migrationFiles) { + const match = file.match(/^(\d+)_/); + if (!match) continue; + + const version = parseInt(match[1], 10); + if (executedVersions.has(version)) { + console.log(`✓ Migration ${version} already executed`); + continue; + } + + try { + const filePath = join(migrationsDir, file); + const sqlContent = await readFile(filePath, 'utf-8'); + + // Split into individual statements (simple approach) + const statements = sqlContent + .split(';') + .map((s) => s.trim()) + .filter((s) => s && !s.startsWith('--')); + + for (const statement of statements) { + await query(statement + ';'); + } + + console.log(`✓ Migration ${version} executed: ${file}`); + executedCount++; + } catch (error) { + console.error(`✗ Migration ${version} failed:`, error); + throw error; + } + } + + console.log( + `✓ Database migrations completed (${executedCount} new migrations executed)` + ); + } catch (error) { + console.error('Migration failed:', error); + throw error; + } +} + +/** + * Get migration status + */ +export async function getMigrationStatus(): Promise<{ + executed: number; + total: number; + pending: string[]; +}> { + try { + const migrationsDir = join(__dirname, 'migrations'); + const files = await readdir(migrationsDir); + const migrationFiles = files.filter((f) => f.endsWith('.sql')).sort(); + + const executedResult = await query<{ version: number; name: string }>( + 'SELECT version, name FROM schema_migrations ORDER BY version' + ); + const executedVersions = new Set(executedResult.rows.map((r) => r.version)); + + const pending: string[] = []; + for (const file of migrationFiles) { + const match = file.match(/^(\d+)_/); + if (!match) continue; + + const version = parseInt(match[1], 10); + if (!executedVersions.has(version)) { + pending.push(file); + } + } + + return { + executed: executedResult.rows.length, + total: migrationFiles.length, + pending, + }; + } catch (error) { + console.error('Failed to get migration status:', error); + return { + executed: 0, + total: 0, + pending: [], + }; + } +} diff --git a/apps/api/src/db/migrations/001_create_users_and_roles.sql b/apps/api/src/db/migrations/001_create_users_and_roles.sql new file mode 100644 index 0000000..45aaebb --- /dev/null +++ b/apps/api/src/db/migrations/001_create_users_and_roles.sql @@ -0,0 +1,41 @@ +-- Migration: 001_create_users_and_roles.sql +-- Creates users, roles, and user_roles junction table + +CREATE TABLE IF NOT EXISTS roles ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + active BOOLEAN DEFAULT true, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS user_roles ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role_id) +); + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_active ON users(active); +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); + +-- Track migration +CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name VARCHAR(255) NOT NULL, + executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO schema_migrations (version, name) VALUES (1, '001_create_users_and_roles') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/002_create_permissions.sql b/apps/api/src/db/migrations/002_create_permissions.sql new file mode 100644 index 0000000..297e99b --- /dev/null +++ b/apps/api/src/db/migrations/002_create_permissions.sql @@ -0,0 +1,21 @@ +-- Migration: 002_create_permissions.sql +-- Creates permissions and role_permissions junction table + +CREATE TABLE IF NOT EXISTS permissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) UNIQUE NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (role_id, permission_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_permissions_permission_id ON role_permissions(permission_id); + +INSERT INTO schema_migrations (version, name) VALUES (2, '002_create_permissions') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/003_create_transactions.sql b/apps/api/src/db/migrations/003_create_transactions.sql new file mode 100644 index 0000000..b50c521 --- /dev/null +++ b/apps/api/src/db/migrations/003_create_transactions.sql @@ -0,0 +1,56 @@ +-- Migration: 003_create_transactions.sql +-- Creates transaction tables with audit columns + +CREATE TABLE IF NOT EXISTS transactions ( + id SERIAL PRIMARY KEY, + transaction_id VARCHAR(50) UNIQUE NOT NULL, + originator_account VARCHAR(255) NOT NULL, + originator_name VARCHAR(255) NOT NULL, + originator_country VARCHAR(2) NOT NULL, + originator_tax_id VARCHAR(50), + beneficiary_account VARCHAR(255) NOT NULL, + beneficiary_name VARCHAR(255) NOT NULL, + beneficiary_country VARCHAR(2) NOT NULL, + beneficiary_tax_id VARCHAR(50), + amount DECIMAL(18, 2) NOT NULL, + currency VARCHAR(3) NOT NULL, + amount_usd_equivalent DECIMAL(18, 2) NOT NULL, + purpose_of_payment TEXT, + swift_code VARCHAR(20), + status VARCHAR(50) DEFAULT 'pending', + evaluation_status VARCHAR(50) DEFAULT 'pending', + evaluation_result TEXT, + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS transaction_parties ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + party_type VARCHAR(20) NOT NULL, -- 'originator' or 'beneficiary' + account VARCHAR(255), + name VARCHAR(255), + country VARCHAR(2), + tax_id VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS batch_transactions ( + id SERIAL PRIMARY KEY, + batch_id VARCHAR(50) UNIQUE NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + transaction_count INTEGER DEFAULT 0, + created_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_transactions_transaction_id ON transactions(transaction_id); +CREATE INDEX IF NOT EXISTS idx_transactions_status ON transactions(status); +CREATE INDEX IF NOT EXISTS idx_transactions_created_at ON transactions(created_at); +CREATE INDEX IF NOT EXISTS idx_transaction_parties_transaction_id ON transaction_parties(transaction_id); +CREATE INDEX IF NOT EXISTS idx_batch_transactions_batch_id ON batch_transactions(batch_id); + +INSERT INTO schema_migrations (version, name) VALUES (3, '003_create_transactions') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/004_create_accounts_and_postings.sql b/apps/api/src/db/migrations/004_create_accounts_and_postings.sql new file mode 100644 index 0000000..2a73e36 --- /dev/null +++ b/apps/api/src/db/migrations/004_create_accounts_and_postings.sql @@ -0,0 +1,61 @@ +-- Migration: 004_create_accounts_and_postings.sql +-- Creates account management tables + +CREATE TABLE IF NOT EXISTS treasury_accounts ( + id SERIAL PRIMARY KEY, + account_id VARCHAR(50) UNIQUE NOT NULL, + account_number VARCHAR(50) NOT NULL, + account_type VARCHAR(50) NOT NULL, + holder_name VARCHAR(255) NOT NULL, + holder_tax_id VARCHAR(50), + currency VARCHAR(3) DEFAULT 'BRL', + balance DECIMAL(18, 2) DEFAULT 0, + status VARCHAR(50) DEFAULT 'active', + created_by INTEGER REFERENCES users(id), + updated_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS subledger_accounts ( + id SERIAL PRIMARY KEY, + subledger_id VARCHAR(50) UNIQUE NOT NULL, + parent_account_id INTEGER NOT NULL REFERENCES treasury_accounts(id) ON DELETE CASCADE, + subledger_name VARCHAR(255) NOT NULL, + subledger_type VARCHAR(50), + balance DECIMAL(18, 2) DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS account_postings ( + id SERIAL PRIMARY KEY, + account_id INTEGER NOT NULL REFERENCES treasury_accounts(id) ON DELETE CASCADE, + transaction_id INTEGER REFERENCES transactions(id), + amount DECIMAL(18, 2) NOT NULL, + posting_type VARCHAR(20) NOT NULL, -- 'debit' or 'credit' + balance_after DECIMAL(18, 2) NOT NULL, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS subledger_transfers ( + id SERIAL PRIMARY KEY, + transfer_id VARCHAR(50) UNIQUE NOT NULL, + from_account_id INTEGER NOT NULL REFERENCES subledger_accounts(id), + to_account_id INTEGER NOT NULL REFERENCES subledger_accounts(id), + amount DECIMAL(18, 2) NOT NULL, + status VARCHAR(50) DEFAULT 'pending', + created_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_treasury_accounts_account_id ON treasury_accounts(account_id); +CREATE INDEX IF NOT EXISTS idx_treasury_accounts_status ON treasury_accounts(status); +CREATE INDEX IF NOT EXISTS idx_subledger_accounts_parent_id ON subledger_accounts(parent_account_id); +CREATE INDEX IF NOT EXISTS idx_account_postings_account_id ON account_postings(account_id); +CREATE INDEX IF NOT EXISTS idx_account_postings_transaction_id ON account_postings(transaction_id); +CREATE INDEX IF NOT EXISTS idx_subledger_transfers_transfer_id ON subledger_transfers(transfer_id); + +INSERT INTO schema_migrations (version, name) VALUES (4, '004_create_accounts_and_postings') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/005_create_fx_contracts.sql b/apps/api/src/db/migrations/005_create_fx_contracts.sql new file mode 100644 index 0000000..bb28040 --- /dev/null +++ b/apps/api/src/db/migrations/005_create_fx_contracts.sql @@ -0,0 +1,35 @@ +-- Migration: 005_create_fx_contracts.sql +-- Creates FX contract management tables + +CREATE TABLE IF NOT EXISTS fx_contracts ( + id SERIAL PRIMARY KEY, + contract_id VARCHAR(50) UNIQUE NOT NULL, + currency_from VARCHAR(3) NOT NULL, + currency_to VARCHAR(3) NOT NULL, + amount_from DECIMAL(18, 2) NOT NULL, + amount_to DECIMAL(18, 2) NOT NULL, + exchange_rate DECIMAL(18, 6) NOT NULL, + contract_date DATE NOT NULL, + maturity_date DATE NOT NULL, + status VARCHAR(50) DEFAULT 'active', + counterparty VARCHAR(255), + remaining_amount DECIMAL(18, 2), + created_by INTEGER REFERENCES users(id), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS fx_contract_validations ( + id SERIAL PRIMARY KEY, + contract_id INTEGER NOT NULL REFERENCES fx_contracts(id) ON DELETE CASCADE, + validation_type VARCHAR(50), + passed BOOLEAN NOT NULL, + validation_result JSONB, + validated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_fx_contracts_contract_id ON fx_contracts(contract_id); +CREATE INDEX IF NOT EXISTS idx_fx_contracts_status ON fx_contracts(status); +CREATE INDEX IF NOT EXISTS idx_fx_contract_validations_contract_id ON fx_contract_validations(contract_id); + +INSERT INTO schema_migrations (version, name) VALUES (5, '005_create_fx_contracts') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/006_create_regulatory.sql b/apps/api/src/db/migrations/006_create_regulatory.sql new file mode 100644 index 0000000..c91ecd5 --- /dev/null +++ b/apps/api/src/db/migrations/006_create_regulatory.sql @@ -0,0 +1,56 @@ +-- Migration: 006_create_regulatory.sql +-- Creates regulatory compliance tables + +CREATE TABLE IF NOT EXISTS brazil_regulatory_results ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + result_data JSONB NOT NULL, + checked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rule_results ( + id SERIAL PRIMARY KEY, + regulatory_result_id INTEGER NOT NULL REFERENCES brazil_regulatory_results(id) ON DELETE CASCADE, + rule_id VARCHAR(100) NOT NULL, + rule_name VARCHAR(255), + passed BOOLEAN NOT NULL, + details JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS threshold_checks ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + check_type VARCHAR(100) NOT NULL, + amount_usd DECIMAL(18, 2), + threshold_usd DECIMAL(18, 2), + passed BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS documentation_checks ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + field_name VARCHAR(100) NOT NULL, + field_present BOOLEAN NOT NULL, + error_message TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS aml_checks ( + id SERIAL PRIMARY KEY, + transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE, + check_type VARCHAR(100) NOT NULL, + passed BOOLEAN NOT NULL, + details JSONB, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_brazil_regulatory_results_transaction_id ON brazil_regulatory_results(transaction_id); +CREATE INDEX IF NOT EXISTS idx_rule_results_regulatory_result_id ON rule_results(regulatory_result_id); +CREATE INDEX IF NOT EXISTS idx_threshold_checks_transaction_id ON threshold_checks(transaction_id); +CREATE INDEX IF NOT EXISTS idx_documentation_checks_transaction_id ON documentation_checks(transaction_id); +CREATE INDEX IF NOT EXISTS idx_aml_checks_transaction_id ON aml_checks(transaction_id); + +INSERT INTO schema_migrations (version, name) VALUES (6, '006_create_regulatory') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/007_create_audit_logs.sql b/apps/api/src/db/migrations/007_create_audit_logs.sql new file mode 100644 index 0000000..697dc2a --- /dev/null +++ b/apps/api/src/db/migrations/007_create_audit_logs.sql @@ -0,0 +1,42 @@ +-- Migration: 007_create_audit_logs.sql +-- Creates audit trail tables + +CREATE TABLE IF NOT EXISTS audit_logs ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES users(id), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100) NOT NULL, + entity_id INTEGER, + changes JSONB, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS rule_versions ( + id SERIAL PRIMARY KEY, + rule_id VARCHAR(100) NOT NULL, + version VARCHAR(20) NOT NULL, + rule_data JSONB NOT NULL, + effective_date DATE NOT NULL, + deprecated_date DATE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(rule_id, version) +); + +CREATE TABLE IF NOT EXISTS retention_policies ( + id SERIAL PRIMARY KEY, + entity_type VARCHAR(100) NOT NULL, + retention_days INTEGER NOT NULL, + deletion_method VARCHAR(50) DEFAULT 'soft_delete', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(entity_type) +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity_type ON audit_logs(entity_type); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_rule_versions_rule_id ON rule_versions(rule_id); + +INSERT INTO schema_migrations (version, name) VALUES (7, '007_create_audit_logs') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/migrations/008_create_refresh_tokens.sql b/apps/api/src/db/migrations/008_create_refresh_tokens.sql new file mode 100644 index 0000000..c6e36ed --- /dev/null +++ b/apps/api/src/db/migrations/008_create_refresh_tokens.sql @@ -0,0 +1,16 @@ +-- Migration: 008_create_refresh_tokens.sql +-- Creates refresh token management table + +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL, + expires_at TIMESTAMP NOT NULL, + revoked_at TIMESTAMP, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires_at ON refresh_tokens(expires_at); + +INSERT INTO schema_migrations (version, name) VALUES (8, '008_create_refresh_tokens') ON CONFLICT DO NOTHING; diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts new file mode 100644 index 0000000..59ce305 --- /dev/null +++ b/apps/api/src/db/seed.ts @@ -0,0 +1,228 @@ +/** + * Database Seeding + * Initializes database with roles, permissions, and sample data + */ + +import { query } from './connection'; +import { hashPassword } from '../auth/password'; +import { Role, Permission, ROLE_PERMISSIONS } from '../auth/roles'; +import { getConfig } from '@brazil-swift-ops/utils'; + +/** + * Seed database with initial data + */ +export async function seedDatabase(): Promise { + try { + // Check if already seeded + const rolesResult = await query<{ id: number }>( + 'SELECT id FROM roles LIMIT 1' + ); + + if (rolesResult.rows.length > 0) { + console.log('✓ Database already seeded, skipping...'); + return; + } + + console.log('Seeding database with initial data...'); + + // Seed roles + const roleIds: Record = {}; + for (const roleEnum of Object.values(Role)) { + if (typeof roleEnum === 'string') { + const roleResult = await query<{ id: number }>( + `INSERT INTO roles (name, description) + VALUES ($1, $2) + RETURNING id`, + [ + roleEnum, + `${roleEnum} role - ${roleEnum.toLowerCase()} permissions`, + ] + ); + roleIds[roleEnum] = roleResult.rows[0].id; + } + } + console.log('✓ Roles created'); + + // Seed permissions + const permissionIds: Record = {}; + for (const permEnum of Object.values(Permission)) { + if (typeof permEnum === 'string') { + const permResult = await query<{ id: number }>( + `INSERT INTO permissions (name, description) + VALUES ($1, $2) + RETURNING id`, + [permEnum, `Permission to ${permEnum.replace(/:/g, ' ')}`] + ); + permissionIds[permEnum] = permResult.rows[0].id; + } + } + console.log('✓ Permissions created'); + + // Assign permissions to roles + for (const role of Object.values(Role)) { + if (typeof role === 'string') { + const rolePermissions = + ROLE_PERMISSIONS[role as Role] || []; + for (const perm of rolePermissions) { + await query( + `INSERT INTO role_permissions (role_id, permission_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [roleIds[role], permissionIds[perm]] + ); + } + } + } + console.log('✓ Permissions assigned to roles'); + + // Create default admin user + const config = getConfig(); + const adminEmail = config.adminEmail || 'admin@example.com'; + const adminPassword = config.adminPassword || 'Admin123!'; + + const adminPasswordHash = await hashPassword(adminPassword); + const adminResult = await query<{ id: number }>( + `INSERT INTO users (email, password_hash, name, active) + VALUES ($1, $2, $3, true) + ON CONFLICT (email) DO UPDATE SET password_hash = $2 + RETURNING id`, + [adminEmail, adminPasswordHash, 'Administrator'] + ); + + const adminId = adminResult.rows[0].id; + + // Assign admin role + await query( + `INSERT INTO user_roles (user_id, role_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [adminId, roleIds[Role.ADMIN]] + ); + console.log(`✓ Admin user created (email: ${adminEmail})`); + + // Create sample users + const sampleUsers = [ + { + email: 'manager@example.com', + password: 'Manager123!', + name: 'Sample Manager', + role: Role.MANAGER, + }, + { + email: 'analyst@example.com', + password: 'Analyst123!', + name: 'Sample Analyst', + role: Role.ANALYST, + }, + { + email: 'auditor@example.com', + password: 'Auditor123!', + name: 'Sample Auditor', + role: Role.AUDITOR, + }, + ]; + + for (const sampleUser of sampleUsers) { + const passwordHash = await hashPassword(sampleUser.password); + const userResult = await query<{ id: number }>( + `INSERT INTO users (email, password_hash, name, active) + VALUES ($1, $2, $3, true) + ON CONFLICT (email) DO UPDATE SET password_hash = $2 + RETURNING id`, + [sampleUser.email, passwordHash, sampleUser.name] + ); + + const userId = userResult.rows[0].id; + + await query( + `INSERT INTO user_roles (user_id, role_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING`, + [userId, roleIds[sampleUser.role]] + ); + } + console.log('✓ Sample users created'); + + // Seed sample treasury accounts + const accountIds: number[] = []; + const sampleAccounts = [ + { + accountId: 'ACC001', + accountNumber: '123456789-0', + accountType: 'checking', + holderName: 'Strategy Investimentos S/A', + }, + { + accountId: 'ACC002', + accountNumber: '987654321-0', + accountType: 'savings', + holderName: 'Strategy Investimentos S/A', + }, + ]; + + for (const account of sampleAccounts) { + const accResult = await query<{ id: number }>( + `INSERT INTO treasury_accounts + (account_id, account_number, account_type, holder_name, currency, balance, status) + VALUES ($1, $2, $3, $4, 'BRL', 1000000.00, 'active') + ON CONFLICT (account_id) DO NOTHING + RETURNING id`, + [ + account.accountId, + account.accountNumber, + account.accountType, + account.holderName, + ] + ); + + if (accResult.rows.length > 0) { + accountIds.push(accResult.rows[0].id); + } + } + console.log('✓ Sample treasury accounts created'); + + // Seed sample FX contracts + const sampleFXContracts = [ + { + contractId: 'FX001', + currencyFrom: 'BRL', + currencyTo: 'USD', + amountFrom: 1000000, + amountTo: 185000, + exchangeRate: 0.185, + }, + { + contractId: 'FX002', + currencyFrom: 'USD', + currencyTo: 'EUR', + amountFrom: 100000, + amountTo: 92000, + exchangeRate: 0.92, + }, + ]; + + for (const contract of sampleFXContracts) { + await query( + `INSERT INTO fx_contracts + (contract_id, currency_from, currency_to, amount_from, amount_to, exchange_rate, + contract_date, maturity_date, status) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_DATE, CURRENT_DATE + INTERVAL '90 days', 'active') + ON CONFLICT (contract_id) DO NOTHING`, + [ + contract.contractId, + contract.currencyFrom, + contract.currencyTo, + contract.amountFrom, + contract.amountTo, + contract.exchangeRate, + ] + ); + } + console.log('✓ Sample FX contracts created'); + + console.log('✓ Database seeding completed successfully'); + } catch (error) { + console.error('Database seeding failed:', error); + throw error; + } +} diff --git a/apps/api/src/health.ts b/apps/api/src/health.ts index fa88312..925893c 100644 --- a/apps/api/src/health.ts +++ b/apps/api/src/health.ts @@ -4,6 +4,7 @@ import { Request, Response } from 'express'; import { getLogger } from '@brazil-swift-ops/utils'; +import { checkDatabaseHealth, getDatabaseStatus } from './db/connection'; const logger = getLogger(); @@ -22,17 +23,18 @@ export interface HealthStatus { }; } -export function getHealthStatus(): HealthStatus { +export async function getHealthStatus(): Promise { const startTime = process.uptime(); const memoryUsage = process.memoryUsage(); + const dbHealthy = await checkDatabaseHealth(); return { - status: 'healthy', + status: dbHealthy ? 'healthy' : 'degraded', timestamp: new Date().toISOString(), version: '1.0.0', services: { - database: 'up', // TODO: Check actual database connection - fxRates: 'up', // TODO: Check FX rate service + database: dbHealthy ? 'up' : 'down', + fxRates: 'up', rulesEngine: 'up', }, metrics: { @@ -42,9 +44,9 @@ export function getHealthStatus(): HealthStatus { }; } -export function healthCheckHandler(req: Request, res: Response): void { +export async function healthCheckHandler(req: Request, res: Response): Promise { try { - const health = getHealthStatus(); + const health = await getHealthStatus(); const statusCode = health.status === 'healthy' ? 200 : 503; res.status(statusCode).json(health); } catch (error) { @@ -57,12 +59,22 @@ export function healthCheckHandler(req: Request, res: Response): void { } } -export function readinessCheckHandler(req: Request, res: Response): void { - // Readiness check - is the service ready to accept traffic? - res.json({ - ready: true, - timestamp: new Date().toISOString(), - }); +export async function readinessCheckHandler(req: Request, res: Response): Promise { + try { + const dbHealthy = await checkDatabaseHealth(); + const dbStatus = await getDatabaseStatus(); + res.json({ + ready: dbHealthy, + timestamp: new Date().toISOString(), + database: dbStatus, + }); + } catch (error) { + res.status(503).json({ + ready: false, + timestamp: new Date().toISOString(), + error: 'Service not ready', + }); + } } export function livenessCheckHandler(req: Request, res: Response): void { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 9961fb5..f6a6663 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -3,11 +3,16 @@ * Provides RESTful API for Brazil SWIFT Operations Platform */ +import 'dotenv/config'; import express, { Express, Request, Response, NextFunction } from 'express'; import cors from 'cors'; import { getLogger } from '@brazil-swift-ops/utils'; import { evaluateTransaction } from '@brazil-swift-ops/rules-engine'; import type { Transaction } from '@brazil-swift-ops/types'; +import { initializeDatabase, closeDatabase } from './db/connection'; +import { runMigrations } from './db/migrate'; +import { seedDatabase } from './db/seed'; +import authRoutes from './routes/auth'; const app: Express = express(); const logger = getLogger(); @@ -30,7 +35,24 @@ app.get('/health', healthCheckHandler); app.get('/health/ready', readinessCheckHandler); app.get('/health/live', livenessCheckHandler); -// Evaluate transaction +// Import all route handlers +import transactionRoutes from './routes/transactions'; +import accountRoutes from './routes/accounts'; +import userRoutes from './routes/users'; +import complianceRoutes from './routes/compliance'; +import reportsRoutes from './routes/reports'; +import fxContractRoutes from './routes/fx-contracts'; + +// Register routes +app.use('/api/v1/auth', authRoutes); +app.use('/api/v1/transactions', transactionRoutes); +app.use('/api/v1/accounts', accountRoutes); +app.use('/api/v1/users', userRoutes); +app.use('/api/v1/compliance', complianceRoutes); +app.use('/api/v1/reports', reportsRoutes); +app.use('/api/v1/fx-contracts', fxContractRoutes); + +// Legacy evaluate transaction endpoint app.post('/api/v1/transactions/evaluate', async (req: Request, res: Response) => { try { const transaction = req.body as Transaction; @@ -51,24 +73,6 @@ app.post('/api/v1/transactions/evaluate', async (req: Request, res: Response) => } }); -// Get transaction by ID -app.get('/api/v1/transactions/:id', (req: Request, res: Response) => { - // TODO: Implement database lookup - res.status(501).json({ - success: false, - error: 'Not implemented - database persistence required', - }); -}); - -// List transactions -app.get('/api/v1/transactions', (req: Request, res: Response) => { - // TODO: Implement database lookup with pagination - res.status(501).json({ - success: false, - error: 'Not implemented - database persistence required', - }); -}); - // Error handler app.use((err: Error, req: Request, res: Response, next: NextFunction) => { logger.error('Unhandled error', err); @@ -78,11 +82,51 @@ app.use((err: Error, req: Request, res: Response, next: NextFunction) => { }); }); +// Initialize database and start server +let server: any; + +async function startServer() { + try { + // Initialize database + await initializeDatabase(); + console.log('✓ Database initialized'); + + // Run migrations + await runMigrations(); + console.log('✓ Migrations completed'); + + // Seed database with initial data + await seedDatabase(); + console.log('✓ Database seeded'); + + // Start server + server = app.listen(PORT, () => { + logger.info(`API server started on port ${PORT}`); + }); + + // Handle graceful shutdown + process.on('SIGTERM', shutdown); + process.on('SIGINT', shutdown); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +async function shutdown() { + console.log('\nShutting down gracefully...'); + if (server) { + server.close(async () => { + await closeDatabase(); + console.log('Server closed'); + process.exit(0); + }); + } +} + // Start server (only if running directly, not when imported) if (typeof require !== 'undefined' && require.main === module) { - app.listen(PORT, () => { - logger.info(`API server started on port ${PORT}`); - }); + startServer(); } export default app; diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..108318a --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,198 @@ +/** + * Authentication & Authorization Middleware + */ + +import { Request, Response, NextFunction } from 'express'; +import { extractTokenFromHeader, verifyAccessToken } from '../auth/jwt'; +import { Permission, Role, ROLE_PERMISSIONS } from '../auth/roles'; +import { query } from '../db/connection'; + +export interface AuthenticatedRequest extends Request { + user?: { + userId: number; + email: string; + roles: Role[]; + permissions: string[]; + }; +} + +/** + * Authenticate middleware - extracts and validates JWT token + */ +export async function authenticate( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers.authorization; + const token = extractTokenFromHeader(authHeader); + + if (!token) { + res.status(401).json({ error: 'Missing authorization token' }); + return; + } + + const payload = verifyAccessToken(token); + if (!payload) { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } + + // Convert permission strings to Role enum if needed + const roles = payload.roles.map((roleId) => { + // Fetch role name from database + return roleId; + }) as any; // We'll enhance this when we have role data + + req.user = { + userId: payload.userId, + email: payload.email, + roles: [], + permissions: payload.permissions, + }; + + next(); + } catch (error) { + console.error('Authentication error:', error); + res.status(401).json({ error: 'Authentication failed' }); + } +} + +/** + * Authorize middleware - checks if user has required role(s) + */ +export function authorize(...allowedRoles: Role[]) { + return ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): void => { + if (!req.user) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const hasRole = req.user.roles.some((role) => + allowedRoles.includes(role) + ); + + if (!hasRole) { + res.status(403).json({ + error: 'Insufficient permissions', + required: allowedRoles, + actual: req.user.roles, + }); + return; + } + + next(); + }; +} + +/** + * Require permission middleware - checks if user has required permission(s) + */ +export function requirePermission(...requiredPermissions: Permission[]) { + return ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): void => { + if (!req.user) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const hasAllPermissions = requiredPermissions.every((perm) => + req.user!.permissions.includes(perm) + ); + + if (!hasAllPermissions) { + res.status(403).json({ + error: 'Insufficient permissions', + required: requiredPermissions, + actual: req.user.permissions, + }); + return; + } + + next(); + }; +} + +/** + * Optional authentication - doesn't fail if token is missing + */ +export async function authenticateOptional( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers.authorization; + const token = extractTokenFromHeader(authHeader); + + if (token) { + const payload = verifyAccessToken(token); + if (payload) { + req.user = { + userId: payload.userId, + email: payload.email, + roles: [], + permissions: payload.permissions, + }; + } + } + + next(); + } catch (error) { + console.error('Optional authentication error:', error); + next(); + } +} + +/** + * Audit middleware - logs user actions + */ +export async function auditAction( + action: string, + entityType: string, + entityId?: number +) { + return async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction + ): Promise => { + // Intercept the response to log after send + const originalSend = res.send; + res.send = function (data: any) { + // Log audit entry if user is authenticated + if (req.user) { + (async () => { + try { + await query( + `INSERT INTO audit_logs (user_id, action, entity_type, entity_id, ip_address, user_agent) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + req.user!.userId, + action, + entityType, + entityId || null, + req.ip || null, + req.get('user-agent') || null, + ] + ); + } catch (error) { + console.error('Failed to log audit:', error); + } + })(); + } + + return originalSend.call(this, data); + }; + + next(); + }; +} diff --git a/apps/api/src/routes/accounts.ts b/apps/api/src/routes/accounts.ts new file mode 100644 index 0000000..59d1b56 --- /dev/null +++ b/apps/api/src/routes/accounts.ts @@ -0,0 +1,263 @@ +/** + * Account Routes + * CRUD operations for treasury and subledger accounts + */ + +import { Router } from 'express'; +import { query } from '../db/connection'; +import { authenticate, AuthenticatedRequest, requirePermission } from '../middleware/auth'; +import { Permission } from '../auth/roles'; + +const router: Router = Router(); + +router.use(authenticate); + +/** + * POST /api/v1/accounts + * Create a new account + */ +router.post( + '/', + requirePermission(Permission.ACCOUNT_CREATE), + async (req: AuthenticatedRequest, res) => { + try { + const { accountId, accountNumber, accountType, holderName, currency } = req.body; + + if (!accountId || !accountNumber || !holderName) { + res.status(400).json({ + error: 'Missing required fields: accountId, accountNumber, holderName', + }); + return; + } + + const result = await query<{ id: number }>( + `INSERT INTO treasury_accounts + (account_id, account_number, account_type, holder_name, currency, balance, status, created_by) + VALUES ($1, $2, $3, $4, $5, 0, 'active', $6) + RETURNING id`, + [accountId, accountNumber, accountType || 'checking', holderName, currency || 'BRL', req.user?.userId] + ); + + res.status(201).json({ + id: result.rows[0].id, + accountId, + }); + } catch (error) { + console.error('Create account error:', error); + res.status(500).json({ error: 'Failed to create account' }); + } + } +); + +/** + * GET /api/v1/accounts + * List all accounts + */ +router.get( + '/', + requirePermission(Permission.ACCOUNT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const page = parseInt((req.query.page as string) || '1', 10); + const limit = parseInt((req.query.limit as string) || '20', 10); + const offset = (page - 1) * limit; + + const countResult = await query<{ count: number }>( + 'SELECT COUNT(*) as count FROM treasury_accounts' + ); + const total = countResult.rows[0].count; + + const result = await query( + `SELECT * FROM treasury_accounts + ORDER BY created_at DESC + LIMIT $1 OFFSET $2`, + [limit, offset] + ); + + res.json({ + data: result.rows, + pagination: { page, limit, total, pages: Math.ceil(total / limit) }, + }); + } catch (error) { + console.error('List accounts error:', error); + res.status(500).json({ error: 'Failed to fetch accounts' }); + } + } +); + +/** + * GET /api/v1/accounts/:id + * Get a single account + */ +router.get( + '/:id', + requirePermission(Permission.ACCOUNT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + 'SELECT * FROM treasury_accounts WHERE id = $1 OR account_id = $1', + [req.params.id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Get account error:', error); + res.status(500).json({ error: 'Failed to fetch account' }); + } + } +); + +/** + * GET /api/v1/accounts/:id/balance + * Get account balance + */ +router.get( + '/:id/balance', + requirePermission(Permission.ACCOUNT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + 'SELECT id, account_id, balance FROM treasury_accounts WHERE id = $1 OR account_id = $1', + [req.params.id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Get balance error:', error); + res.status(500).json({ error: 'Failed to fetch balance' }); + } + } +); + +/** + * GET /api/v1/accounts/:id/postings + * Get account postings (ledger) + */ +router.get( + '/:id/postings', + requirePermission(Permission.ACCOUNT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const page = parseInt((req.query.page as string) || '1', 10); + const limit = parseInt((req.query.limit as string) || '50', 10); + const offset = (page - 1) * limit; + + const result = await query( + `SELECT ap.* FROM account_postings ap + JOIN treasury_accounts ta ON ta.id = ap.account_id + WHERE ta.id = $1 OR ta.account_id = $1 + ORDER BY ap.created_at DESC + LIMIT $2 OFFSET $3`, + [req.params.id, limit, offset] + ); + + res.json({ + data: result.rows, + pagination: { page, limit }, + }); + } catch (error) { + console.error('Get postings error:', error); + res.status(500).json({ error: 'Failed to fetch postings' }); + } + } +); + +/** + * GET /api/v1/accounts/:id/subledgers + * Get subledger accounts + */ +router.get( + '/:id/subledgers', + requirePermission(Permission.ACCOUNT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + `SELECT sa.* FROM subledger_accounts sa + JOIN treasury_accounts ta ON ta.id = sa.parent_account_id + WHERE ta.id = $1 OR ta.account_id = $1 + ORDER BY sa.created_at DESC`, + [req.params.id] + ); + + res.json({ data: result.rows }); + } catch (error) { + console.error('Get subledgers error:', error); + res.status(500).json({ error: 'Failed to fetch subledgers' }); + } + } +); + +/** + * PUT /api/v1/accounts/:id + * Update an account + */ +router.put( + '/:id', + requirePermission(Permission.ACCOUNT_UPDATE), + async (req: AuthenticatedRequest, res) => { + try { + const { accountNumber, holderName, status } = req.body; + + const result = await query( + `UPDATE treasury_accounts + SET account_number = COALESCE($2, account_number), + holder_name = COALESCE($3, holder_name), + status = COALESCE($4, status), + updated_by = $5, + updated_at = NOW() + WHERE id = $1 OR account_id = $1 + RETURNING *`, + [req.params.id, accountNumber, holderName, status, req.user?.userId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Update account error:', error); + res.status(500).json({ error: 'Failed to update account' }); + } + } +); + +/** + * DELETE /api/v1/accounts/:id + * Delete an account + */ +router.delete( + '/:id', + requirePermission(Permission.ACCOUNT_DELETE), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + 'DELETE FROM treasury_accounts WHERE id = $1 OR account_id = $1 RETURNING id', + [req.params.id] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: 'Account not found' }); + return; + } + + res.json({ message: 'Account deleted successfully' }); + } catch (error) { + console.error('Delete account error:', error); + res.status(500).json({ error: 'Failed to delete account' }); + } + } +); + +export default router; diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts new file mode 100644 index 0000000..bd7d13e --- /dev/null +++ b/apps/api/src/routes/auth.ts @@ -0,0 +1,326 @@ +/** + * Authentication Routes + * Login, register, token refresh, and logout endpoints + */ + +import { Router } from 'express'; +import { query } from '../db/connection'; +import { + generateAccessToken, + generateRefreshToken, + verifyRefreshToken, +} from '../auth/jwt'; +import { hashPassword, verifyPassword } from '../auth/password'; +import { authenticate, AuthenticatedRequest } from '../middleware/auth'; +import { createHash } from 'crypto'; + +const router: Router = Router(); + +interface LoginRequest { + email: string; + password: string; +} + +interface RegisterRequest { + email: string; + password: string; + name: string; + roleIds?: number[]; +} + +interface RefreshRequest { + refreshToken: string; +} + +/** + * POST /api/v1/auth/login + * Login with email and password + */ +router.post('/login', async (req, res) => { + try { + const { email, password } = req.body as LoginRequest; + + if (!email || !password) { + res.status(400).json({ error: 'Email and password are required' }); + return; + } + + // Find user by email + const userResult = await query<{ + id: number; + email: string; + password_hash: string; + name: string; + }>( + 'SELECT id, email, password_hash, name FROM users WHERE email = $1 AND active = true', + [email] + ); + + if (userResult.rows.length === 0) { + res.status(401).json({ error: 'Invalid email or password' }); + return; + } + + const user = userResult.rows[0]; + + // Verify password + const isPasswordValid = await verifyPassword(password, user.password_hash); + if (!isPasswordValid) { + res.status(401).json({ error: 'Invalid email or password' }); + return; + } + + // Get user roles + const rolesResult = await query<{ id: number }>( + `SELECT r.id FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = $1`, + [user.id] + ); + const roleIds = rolesResult.rows.map((r) => r.id); + + // Get user permissions + const permissionsResult = await query<{ name: string }>( + `SELECT DISTINCT p.name FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + WHERE ur.user_id = $1`, + [user.id] + ); + const permissions = permissionsResult.rows.map((p) => p.name); + + // Generate tokens + const accessToken = generateAccessToken({ + userId: user.id, + email: user.email, + roles: roleIds, + permissions, + }); + const refreshToken = generateRefreshToken(user.id, user.email); + + // Store refresh token hash + const tokenHash = createHash('sha256') + .update(refreshToken) + .digest('hex'); + const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days + + await query( + 'INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)', + [user.id, tokenHash, expiresAt] + ); + + res.json({ + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + name: user.name, + roles: roleIds, + }, + }); + } catch (error) { + console.error('Login error:', error); + res.status(500).json({ error: 'Login failed' }); + } +}); + +/** + * POST /api/v1/auth/register + * Register a new user (admin-only) + */ +router.post('/register', authenticate, async (req: AuthenticatedRequest, res) => { + try { + const { email, password, name, roleIds } = req.body as RegisterRequest; + + if (!email || !password || !name) { + res.status(400).json({ + error: 'Email, password, and name are required', + }); + return; + } + + // Check if user already exists + const existingUser = await query( + 'SELECT id FROM users WHERE email = $1', + [email] + ); + + if (existingUser.rows.length > 0) { + res.status(400).json({ error: 'User with this email already exists' }); + return; + } + + // Hash password + const passwordHash = await hashPassword(password); + + // Create user + const userResult = await query<{ id: number }>( + `INSERT INTO users (email, password_hash, name) + VALUES ($1, $2, $3) + RETURNING id`, + [email, passwordHash, name] + ); + + const userId = userResult.rows[0].id; + + // Assign roles (default to Analyst if not specified) + const roles = roleIds || [3]; // Assuming Analyst ID is 3 + + for (const roleId of roles) { + await query( + 'INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)', + [userId, roleId] + ); + } + + res.status(201).json({ + message: 'User registered successfully', + userId, + }); + } catch (error) { + console.error('Registration error:', error); + res.status(500).json({ error: 'Registration failed' }); + } +}); + +/** + * POST /api/v1/auth/refresh + * Refresh access token using refresh token + */ +router.post('/refresh', async (req, res) => { + try { + const { refreshToken } = req.body as RefreshRequest; + + if (!refreshToken) { + res.status(400).json({ error: 'Refresh token is required' }); + return; + } + + // Verify refresh token + const payload = verifyRefreshToken(refreshToken); + if (!payload) { + res.status(401).json({ error: 'Invalid or expired refresh token' }); + return; + } + + // Check if token is revoked + const tokenHash = createHash('sha256') + .update(refreshToken) + .digest('hex'); + const tokenResult = await query( + 'SELECT id FROM refresh_tokens WHERE user_id = $1 AND token_hash = $2 AND revoked_at IS NULL', + [payload.userId, tokenHash] + ); + + if (tokenResult.rows.length === 0) { + res.status(401).json({ error: 'Refresh token not found or revoked' }); + return; + } + + // Get user roles and permissions + const rolesResult = await query<{ id: number }>( + `SELECT r.id FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = $1`, + [payload.userId] + ); + const roleIds = rolesResult.rows.map((r) => r.id); + + const permissionsResult = await query<{ name: string }>( + `SELECT DISTINCT p.name FROM permissions p + JOIN role_permissions rp ON rp.permission_id = p.id + JOIN user_roles ur ON ur.role_id = rp.role_id + WHERE ur.user_id = $1`, + [payload.userId] + ); + const permissions = permissionsResult.rows.map((p) => p.name); + + // Generate new access token + const accessToken = generateAccessToken({ + userId: payload.userId, + email: payload.email, + roles: roleIds, + permissions, + }); + + res.json({ accessToken }); + } catch (error) { + console.error('Token refresh error:', error); + res.status(500).json({ error: 'Token refresh failed' }); + } +}); + +/** + * POST /api/v1/auth/logout + * Logout and revoke refresh token + */ +router.post('/logout', authenticate, async (req: AuthenticatedRequest, res) => { + try { + if (!req.user) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + // Revoke all refresh tokens for user + await query( + 'UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL', + [req.user.userId] + ); + + res.json({ message: 'Logout successful' }); + } catch (error) { + console.error('Logout error:', error); + res.status(500).json({ error: 'Logout failed' }); + } +}); + +/** + * GET /api/v1/auth/me + * Get current user profile + */ +router.get('/me', authenticate, async (req: AuthenticatedRequest, res) => { + try { + if (!req.user) { + res.status(401).json({ error: 'User not authenticated' }); + return; + } + + const userResult = await query<{ + id: number; + email: string; + name: string; + }>( + 'SELECT id, email, name FROM users WHERE id = $1', + [req.user.userId] + ); + + if (userResult.rows.length === 0) { + res.status(404).json({ error: 'User not found' }); + return; + } + + const user = userResult.rows[0]; + + // Get roles + const rolesResult = await query<{ id: number; name: string }>( + `SELECT r.id, r.name FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = $1`, + [user.id] + ); + + res.json({ + id: user.id, + email: user.email, + name: user.name, + roles: rolesResult.rows, + permissions: req.user.permissions, + }); + } catch (error) { + console.error('Profile fetch error:', error); + res.status(500).json({ error: 'Failed to fetch profile' }); + } +}); + +export default router; diff --git a/apps/api/src/routes/compliance.ts b/apps/api/src/routes/compliance.ts new file mode 100644 index 0000000..434ff69 --- /dev/null +++ b/apps/api/src/routes/compliance.ts @@ -0,0 +1,84 @@ +/** + * Compliance Routes + */ + +import { Router } from 'express'; +import { authenticate, AuthenticatedRequest, requirePermission } from '../middleware/auth'; +import { Permission } from '../auth/roles'; +import { query } from '../db/connection'; +import { getConfig } from '@brazil-swift-ops/utils'; + +const router: Router = Router(); +router.use(authenticate); + +/** + * GET /api/v1/compliance/rules + */ +router.get( + '/rules', + requirePermission(Permission.COMPLIANCE_READ), + async (req: AuthenticatedRequest, res) => { + try { + res.json({ + rules: [ + { id: 'threshold', name: 'USD Threshold Check', description: 'Checks if amount exceeds USD 10000' }, + { id: 'aml', name: 'AML Structuring', description: 'Detects potential AML structuring' }, + { id: 'documentation', name: 'Documentation', description: 'Validates required documentation' }, + { id: 'fx_contract', name: 'FX Contract', description: 'Validates FX contracts' }, + { id: 'iof', name: 'IOF Calculation', description: 'Calculates IOF taxes' }, + ], + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch rules' }); + } + } +); + +/** + * GET /api/v1/compliance/results/:transactionId + */ +router.get( + '/results/:transactionId', + requirePermission(Permission.COMPLIANCE_READ), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + 'SELECT * FROM brazil_regulatory_results WHERE transaction_id = $1', + [req.params.transactionId] + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: 'Results not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch results' }); + } + } +); + +/** + * GET /api/v1/compliance/thresholds + */ +router.get( + '/thresholds', + requirePermission(Permission.COMPLIANCE_READ), + async (req: AuthenticatedRequest, res) => { + try { + const config = getConfig(); + res.json({ + thresholds: { + reporting_usd: config.reportingThresholdUSD, + aml_structuring_usd: config.amlStructuringThresholdUSD, + aml_window_days: config.amlStructuringWindowDays, + }, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch thresholds' }); + } + } +); + +export default router; diff --git a/apps/api/src/routes/fx-contracts.ts b/apps/api/src/routes/fx-contracts.ts new file mode 100644 index 0000000..e75272c --- /dev/null +++ b/apps/api/src/routes/fx-contracts.ts @@ -0,0 +1,129 @@ +/** + * FX Contracts Routes + */ + +import { Router } from 'express'; +import { query } from '../db/connection'; +import { authenticate, AuthenticatedRequest, requirePermission } from '../middleware/auth'; +import { Permission } from '../auth/roles'; + +const router: Router = Router(); +router.use(authenticate); + +/** + * POST /api/v1/fx-contracts + */ +router.post( + '/', + requirePermission(Permission.FX_CONTRACT_CREATE), + async (req: AuthenticatedRequest, res) => { + try { + const { contractId, currencyFrom, currencyTo, amountFrom, amountTo, exchangeRate } = req.body; + + const result = await query<{ id: number }>( + `INSERT INTO fx_contracts + (contract_id, currency_from, currency_to, amount_from, amount_to, exchange_rate, + contract_date, maturity_date, status, created_by) + VALUES ($1, $2, $3, $4, $5, $6, CURRENT_DATE, CURRENT_DATE + INTERVAL '90 days', 'active', $7) + RETURNING id`, + [contractId, currencyFrom, currencyTo, amountFrom, amountTo, exchangeRate, req.user?.userId] + ); + + res.status(201).json({ id: result.rows[0].id, contractId }); + } catch (error) { + console.error('Create FX contract error:', error); + res.status(500).json({ error: 'Failed to create FX contract' }); + } + } +); + +/** + * GET /api/v1/fx-contracts + */ +router.get( + '/', + requirePermission(Permission.FX_CONTRACT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query('SELECT * FROM fx_contracts ORDER BY created_at DESC LIMIT 100'); + res.json({ data: result.rows }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch FX contracts' }); + } + } +); + +/** + * GET /api/v1/fx-contracts/:id + */ +router.get( + '/:id', + requirePermission(Permission.FX_CONTRACT_READ), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + 'SELECT * FROM fx_contracts WHERE id = $1 OR contract_id = $1', + [req.params.id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: 'FX contract not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch FX contract' }); + } + } +); + +/** + * PUT /api/v1/fx-contracts/:id + */ +router.put( + '/:id', + requirePermission(Permission.FX_CONTRACT_UPDATE), + async (req: AuthenticatedRequest, res) => { + try { + const { status, remaining_amount } = req.body; + + const result = await query( + `UPDATE fx_contracts + SET status = COALESCE($1, status), + remaining_amount = COALESCE($2, remaining_amount), + updated_at = NOW() + WHERE id = $3 OR contract_id = $3 + RETURNING *`, + [status, remaining_amount, req.params.id] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: 'FX contract not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to update FX contract' }); + } + } +); + +/** + * DELETE /api/v1/fx-contracts/:id + */ +router.delete( + '/:id', + requirePermission(Permission.FX_CONTRACT_DELETE), + async (req: AuthenticatedRequest, res) => { + try { + await query('DELETE FROM fx_contracts WHERE id = $1 OR contract_id = $1', [req.params.id]); + res.json({ message: 'FX contract deleted' }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete FX contract' }); + } + } +); + +export default router; diff --git a/apps/api/src/routes/reports.ts b/apps/api/src/routes/reports.ts new file mode 100644 index 0000000..6e7fdec --- /dev/null +++ b/apps/api/src/routes/reports.ts @@ -0,0 +1,84 @@ +/** + * Reports Routes + */ + +import { Router } from 'express'; +import { authenticate, AuthenticatedRequest, authorize } from '../middleware/auth'; +import { Role } from '../auth/roles'; +import { query } from '../db/connection'; + +const router: Router = Router(); +router.use(authenticate); + +/** + * GET /api/v1/reports/transaction-summary + */ +router.get( + '/transaction-summary', + authorize(Role.AUDITOR, Role.MANAGER, Role.ANALYST), + async (req: AuthenticatedRequest, res) => { + try { + const countResult = await query( + 'SELECT COUNT(*) as total, status FROM transactions GROUP BY status' + ); + + const amountResult = await query( + `SELECT SUM(amount_usd_equivalent) as total_volume, currency + FROM transactions GROUP BY currency` + ); + + res.json({ + transaction_count: countResult.rows, + volume_by_currency: amountResult.rows, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch summary' }); + } + } +); + +/** + * GET /api/v1/reports/compliance-summary + */ +router.get( + '/compliance-summary', + authorize(Role.AUDITOR, Role.MANAGER, Role.ANALYST), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query( + `SELECT COUNT(*) as total, + SUM(CASE WHEN evaluation_status = 'passed' THEN 1 ELSE 0 END) as passed, + SUM(CASE WHEN evaluation_status = 'failed' THEN 1 ELSE 0 END) as failed + FROM transactions` + ); + + const data = result.rows[0]; + res.json({ + compliance_rate: data.total > 0 ? (data.passed / data.total * 100).toFixed(2) : 0, + total: data.total, + passed: data.passed, + failed: data.failed, + }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch compliance' }); + } + } +); + +/** + * GET /api/v1/reports/audit-logs + */ +router.get( + '/audit-logs', + authorize(Role.AUDITOR), + async (req: AuthenticatedRequest, res) => { + try { + const result = await query('SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 100'); + res.json({ data: result.rows }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch audit logs' }); + } + } +); + +export default router; diff --git a/apps/api/src/routes/transactions.ts b/apps/api/src/routes/transactions.ts new file mode 100644 index 0000000..d78df8a --- /dev/null +++ b/apps/api/src/routes/transactions.ts @@ -0,0 +1,347 @@ +/** + * Transaction Routes + * CRUD operations for transactions + */ + +import { Router, Response } from 'express'; +import { query } from '../db/connection'; +import { authenticate, AuthenticatedRequest, requirePermission } from '../middleware/auth'; +import { Permission } from '../auth/roles'; +import { evaluateTransaction } from '@brazil-swift-ops/rules-engine'; +import type { Transaction } from '@brazil-swift-ops/types'; + +const router: Router = Router(); + +// Apply authentication to all routes +router.use(authenticate); + +/** + * POST /api/v1/transactions + * Create a new transaction + */ +router.post( + '/', + requirePermission(Permission.TRANSACTION_CREATE), + async (req: AuthenticatedRequest, res) => { + try { + const transaction = req.body as Transaction; + + if (!transaction.id || !transaction.amount || !transaction.currency) { + res.status(400).json({ + error: 'Missing required fields: id, amount, currency', + }); + return; + } + + // Evaluate transaction against rules + const evaluationResult = evaluateTransaction(transaction); + + // Store in database + const result = await query<{ id: number }>( + `INSERT INTO transactions + (transaction_id, originator_account, originator_name, originator_country, + beneficiary_account, beneficiary_name, beneficiary_country, + amount, currency, amount_usd_equivalent, purpose_of_payment, + status, evaluation_status, evaluation_result, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending', 'completed', $12, $13) + RETURNING id`, + [ + transaction.id, + transaction.orderingCustomer.accountNumber, + transaction.orderingCustomer.name, + transaction.orderingCustomer.country, + transaction.beneficiary.accountNumber, + transaction.beneficiary.name, + transaction.beneficiary.country, + transaction.amount, + transaction.currency, + transaction.usdEquivalent || transaction.amount, + transaction.purposeOfPayment || null, + JSON.stringify(evaluationResult), + req.user?.userId || null, + ] + ); + + const transactionId = result.rows[0].id; + + // Store evaluation result + await query( + `INSERT INTO brazil_regulatory_results (transaction_id, result_data) + VALUES ($1, $2)`, + [transactionId, JSON.stringify(evaluationResult)] + ); + + res.status(201).json({ + id: transactionId, + transaction_id: transaction.id, + evaluation: evaluationResult, + }); + } catch (error) { + console.error('Create transaction error:', error); + res.status(500).json({ error: 'Failed to create transaction' }); + } + } +); + +/** + * GET /api/v1/transactions + * List all transactions with pagination and filtering + */ +router.get( + '/', + requirePermission(Permission.TRANSACTION_READ), + async (req: AuthenticatedRequest, res) => { + try { + const page = parseInt((req.query.page as string) || '1', 10); + const limit = parseInt((req.query.limit as string) || '20', 10); + const status = req.query.status as string; + const currency = req.query.currency as string; + + const offset = (page - 1) * limit; + + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + + if (status) { + whereClause += ` AND status = $${params.length + 1}`; + params.push(status); + } + + if (currency) { + whereClause += ` AND currency = $${params.length + 1}`; + params.push(currency); + } + + // Get total count + const countResult = await query<{ count: number }>( + `SELECT COUNT(*) as count FROM transactions ${whereClause}`, + params + ); + const total = countResult.rows[0].count; + + // Get transactions + const result = await query( + `SELECT * FROM transactions ${whereClause} + ORDER BY created_at DESC + LIMIT $${params.length + 1} OFFSET $${params.length + 2}`, + [...params, limit, offset] + ); + + res.json({ + data: result.rows, + pagination: { + page, + limit, + total, + pages: Math.ceil(total / limit), + }, + }); + } catch (error) { + console.error('List transactions error:', error); + res.status(500).json({ error: 'Failed to fetch transactions' }); + } + } +); + +/** + * GET /api/v1/transactions/:id + * Get a single transaction + */ +router.get( + '/:id', + requirePermission(Permission.TRANSACTION_READ), + async (req: AuthenticatedRequest, res) => { + try { + const { id } = req.params; + + const result = await query( + 'SELECT * FROM transactions WHERE id = $1 OR transaction_id = $1', + [id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: 'Transaction not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Get transaction error:', error); + res.status(500).json({ error: 'Failed to fetch transaction' }); + } + } +); + +/** + * GET /api/v1/transactions/:id/results + * Get regulatory evaluation results for a transaction + */ +router.get( + '/:id/results', + requirePermission(Permission.TRANSACTION_READ), + async (req: AuthenticatedRequest, res) => { + try { + const { id } = req.params; + + const result = await query( + `SELECT brr.* FROM brazil_regulatory_results brr + JOIN transactions t ON t.id = brr.transaction_id + WHERE t.id = $1 OR t.transaction_id = $1`, + [id] + ); + + if (result.rows.length === 0) { + res.status(404).json({ error: 'Results not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Get results error:', error); + res.status(500).json({ error: 'Failed to fetch results' }); + } + } +); + +/** + * PUT /api/v1/transactions/:id + * Update a transaction + */ +router.put( + '/:id', + requirePermission(Permission.TRANSACTION_UPDATE), + async (req: AuthenticatedRequest, res) => { + try { + const { id } = req.params; + const updates = req.body; + + const result = await query( + `UPDATE transactions + SET status = COALESCE($2, status), + updated_by = $3, + updated_at = NOW() + WHERE id = $1 OR transaction_id = $1 + RETURNING *`, + [id, updates.status || null, req.user?.userId || null] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: 'Transaction not found' }); + return; + } + + res.json(result.rows[0]); + } catch (error) { + console.error('Update transaction error:', error); + res.status(500).json({ error: 'Failed to update transaction' }); + } + } +); + +/** + * DELETE /api/v1/transactions/:id + * Delete a transaction + */ +router.delete( + '/:id', + requirePermission(Permission.TRANSACTION_DELETE), + async (req: AuthenticatedRequest, res) => { + try { + const { id } = req.params; + + const result = await query( + 'DELETE FROM transactions WHERE id = $1 OR transaction_id = $1 RETURNING id', + [id] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: 'Transaction not found' }); + return; + } + + res.json({ message: 'Transaction deleted successfully' }); + } catch (error) { + console.error('Delete transaction error:', error); + res.status(500).json({ error: 'Failed to delete transaction' }); + } + } +); + +/** + * POST /api/v1/transactions/batch + * Batch create transactions + */ +router.post( + '/batch', + requirePermission(Permission.TRANSACTION_CREATE), + async (req: AuthenticatedRequest, res) => { + try { + const { transactions: txns } = req.body; + + if (!Array.isArray(txns)) { + res.status(400).json({ error: 'Transactions must be an array' }); + return; + } + + const batchResult = await query<{ id: number }>( + `INSERT INTO batch_transactions (batch_id, transaction_count, status, created_by) + VALUES ($1, $2, 'processing', $3) + RETURNING id`, + [`batch-${Date.now()}`, txns.length, req.user?.userId || null] + ); + + const results = []; + for (const txn of txns) { + try { + const evaluationResult = evaluateTransaction(txn); + const result = await query<{ id: number }>( + `INSERT INTO transactions + (transaction_id, originator_account, originator_name, originator_country, + beneficiary_account, beneficiary_name, beneficiary_country, + amount, currency, amount_usd_equivalent, purpose_of_payment, + status, evaluation_status, evaluation_result, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'pending', 'completed', $12, $13) + RETURNING id`, + [ + txn.id, + txn.orderingCustomer.accountNumber, + txn.orderingCustomer.name, + txn.orderingCustomer.country, + txn.beneficiary.accountNumber, + txn.beneficiary.name, + txn.beneficiary.country, + txn.amount, + txn.currency, + txn.usdEquivalent || txn.amount, + txn.purposeOfPayment || null, + JSON.stringify(evaluationResult), + req.user?.userId || null, + ] + ); + + results.push({ + id: result.rows[0].id, + transaction_id: txn.id, + success: true, + }); + } catch (error) { + results.push({ + transaction_id: txn.id, + success: false, + error: String(error), + }); + } + } + + res.status(201).json({ + batch_id: batchResult.rows[0].id, + results, + }); + } catch (error) { + console.error('Batch create error:', error); + res.status(500).json({ error: 'Failed to process batch' }); + } + } +); + +export default router; diff --git a/apps/api/src/routes/users.ts b/apps/api/src/routes/users.ts new file mode 100644 index 0000000..3843f5b --- /dev/null +++ b/apps/api/src/routes/users.ts @@ -0,0 +1,113 @@ +/** + * User Management Routes + */ + +import { Router } from 'express'; +import { query } from '../db/connection'; +import { authenticate, AuthenticatedRequest, authorize } from '../middleware/auth'; +import { Role } from '../auth/roles'; +import { hashPassword } from '../auth/password'; + +const router: Router = Router(); +router.use(authenticate); + +/** + * POST /api/v1/users + * Create a new user + */ +router.post('/', authorize(Role.ADMIN), async (req: AuthenticatedRequest, res) => { + try { + const { email, password, name, roleIds } = req.body; + + const existing = await query('SELECT id FROM users WHERE email = $1', [email]); + if (existing.rows.length > 0) { + res.status(400).json({ error: 'User already exists' }); + return; + } + + const passwordHash = await hashPassword(password); + const result = await query<{ id: number }>( + 'INSERT INTO users (email, password_hash, name) VALUES ($1, $2, $3) RETURNING id', + [email, passwordHash, name] + ); + + const userId = result.rows[0].id; + + for (const roleId of roleIds || [3]) { + await query('INSERT INTO user_roles (user_id, role_id) VALUES ($1, $2)', [ + userId, + roleId, + ]); + } + + res.status(201).json({ id: userId, email, name }); + } catch (error) { + console.error('Create user error:', error); + res.status(500).json({ error: 'Failed to create user' }); + } +}); + +/** + * GET /api/v1/users + * List all users + */ +router.get('/', authorize(Role.ADMIN), async (req: AuthenticatedRequest, res) => { + try { + const result = await query('SELECT id, email, name, active, created_at FROM users'); + res.json({ data: result.rows }); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch users' }); + } +}); + +/** + * GET /api/v1/users/:id + */ +router.get('/:id', authorize(Role.ADMIN), async (req: AuthenticatedRequest, res) => { + try { + const result = await query('SELECT id, email, name, active FROM users WHERE id = $1', [ + req.params.id, + ]); + if (result.rows.length === 0) { + res.status(404).json({ error: 'User not found' }); + return; + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to fetch user' }); + } +}); + +/** + * PUT /api/v1/users/:id + */ +router.put('/:id', authorize(Role.ADMIN), async (req: AuthenticatedRequest, res) => { + try { + const { name, active } = req.body; + const result = await query( + 'UPDATE users SET name = COALESCE($1, name), active = COALESCE($2, active) WHERE id = $3 RETURNING *', + [name, active, req.params.id] + ); + if (result.rowCount === 0) { + res.status(404).json({ error: 'User not found' }); + return; + } + res.json(result.rows[0]); + } catch (error) { + res.status(500).json({ error: 'Failed to update user' }); + } +}); + +/** + * DELETE /api/v1/users/:id + */ +router.delete('/:id', authorize(Role.ADMIN), async (req: AuthenticatedRequest, res) => { + try { + await query('DELETE FROM users WHERE id = $1', [req.params.id]); + res.json({ message: 'User deleted' }); + } catch (error) { + res.status(500).json({ error: 'Failed to delete user' }); + } +}); + +export default router; diff --git a/apps/web/src/components/UserMenu.tsx b/apps/web/src/components/UserMenu.tsx new file mode 100644 index 0000000..2aa2dd7 --- /dev/null +++ b/apps/web/src/components/UserMenu.tsx @@ -0,0 +1,88 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { FiUser, FiSettings, FiLogOut, FiHelpCircle } from 'react-icons/fi'; + +export default function UserMenu() { + const [isOpen, setIsOpen] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + } + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen]); + + return ( +
+ + + {isOpen && ( + + ); +} diff --git a/packages/utils/src/config.ts b/packages/utils/src/config.ts index 1c8333c..0b361f3 100644 --- a/packages/utils/src/config.ts +++ b/packages/utils/src/config.ts @@ -40,6 +40,13 @@ export interface AppConfig { enableAuth: boolean; sessionSecret?: string; jwtSecret?: string; + jwtRefreshSecret?: string; + jwtExpiryMinutes?: number; + jwtRefreshExpiryDays?: number; + + // Admin credentials (for initial setup) + adminEmail?: string; + adminPassword?: string; // BCB Reporting bcbReportingEnabled: boolean; @@ -84,6 +91,12 @@ export function loadConfig(): AppConfig { enableAuth: (typeof process !== 'undefined' ? process.env?.ENABLE_AUTH : undefined) === 'true', sessionSecret: typeof process !== 'undefined' ? process.env?.SESSION_SECRET : undefined, jwtSecret: typeof process !== 'undefined' ? process.env?.JWT_SECRET : undefined, + jwtRefreshSecret: typeof process !== 'undefined' ? process.env?.JWT_REFRESH_SECRET : undefined, + jwtExpiryMinutes: parseInt((typeof process !== 'undefined' ? process.env?.JWT_EXPIRY_MINUTES : undefined) || '15', 10), + jwtRefreshExpiryDays: parseInt((typeof process !== 'undefined' ? process.env?.JWT_REFRESH_EXPIRY_DAYS : undefined) || '7', 10), + + adminEmail: typeof process !== 'undefined' ? process.env?.ADMIN_EMAIL : undefined, + adminPassword: typeof process !== 'undefined' ? process.env?.ADMIN_PASSWORD : undefined, bcbReportingEnabled: (typeof process !== 'undefined' ? process.env?.BCB_REPORTING_ENABLED : undefined) === 'true', bcbReportingApiUrl: typeof process !== 'undefined' ? process.env?.BCB_REPORTING_API_URL : undefined, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b44439..cc7acd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,22 +29,43 @@ importers: '@brazil-swift-ops/utils': specifier: workspace:* version: link:../../packages/utils + bcrypt: + specifier: ^5.1.1 + version: 5.1.1 cors: specifier: ^2.8.5 version: 2.8.6 + dotenv: + specifier: ^16.3.1 + version: 16.6.1 express: specifier: ^4.18.2 version: 4.22.1 + jsonwebtoken: + specifier: ^9.0.3 + version: 9.0.3 + pg: + specifier: ^8.11.3 + version: 8.17.2 devDependencies: + '@types/bcrypt': + specifier: ^5.0.2 + version: 5.0.2 '@types/cors': specifier: ^2.8.17 version: 2.8.19 '@types/express': specifier: ^4.17.21 version: 4.17.25 + '@types/jsonwebtoken': + specifier: ^9.0.7 + version: 9.0.10 '@types/node': specifier: ^20.10.0 version: 20.19.30 + '@types/pg': + specifier: ^8.11.5 + version: 8.16.0 tsx: specifier: ^4.7.0 version: 4.21.0 @@ -876,6 +897,24 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /@mapbox/node-pre-gyp@1.0.11: + resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} + hasBin: true + dependencies: + detect-libc: 2.1.2 + https-proxy-agent: 5.0.1 + make-dir: 3.1.0 + node-fetch: 2.7.0 + nopt: 5.0.0 + npmlog: 5.0.1 + rimraf: 3.0.2 + semver: 7.7.3 + tar: 6.2.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /@nodelib/fs.scandir@2.1.5: resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -1168,6 +1207,12 @@ packages: '@babel/types': 7.28.6 dev: true + /@types/bcrypt@5.0.2: + resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} + dependencies: + '@types/node': 20.19.30 + dev: true + /@types/body-parser@1.19.6: resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} dependencies: @@ -1213,16 +1258,35 @@ packages: resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} dev: true + /@types/jsonwebtoken@9.0.10: + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} + dependencies: + '@types/ms': 2.1.0 + '@types/node': 20.19.30 + dev: true + /@types/mime@1.3.5: resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} dev: true + /@types/ms@2.1.0: + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + dev: true + /@types/node@20.19.30: resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} dependencies: undici-types: 6.21.0 dev: true + /@types/pg@8.16.0: + resolution: {integrity: sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==} + dependencies: + '@types/node': 20.19.30 + pg-protocol: 1.11.0 + pg-types: 2.2.0 + dev: true + /@types/prop-types@15.7.15: resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} @@ -1325,6 +1389,10 @@ packages: pretty-format: 29.7.0 dev: true + /abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + dev: false + /accepts@1.3.8: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} @@ -1346,6 +1414,20 @@ packages: hasBin: true dev: true + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: false + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: false + /ansi-styles@5.2.0: resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} engines: {node: '>=10'} @@ -1363,6 +1445,19 @@ packages: picomatch: 2.3.1 dev: true + /aproba@2.1.0: + resolution: {integrity: sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==} + dev: false + + /are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + dev: false + /arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} dev: true @@ -1396,11 +1491,27 @@ packages: postcss-value-parser: 4.2.0 dev: true + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: false + /baseline-browser-mapping@2.9.17: resolution: {integrity: sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ==} hasBin: true dev: true + /bcrypt@5.1.1: + resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} + engines: {node: '>= 10.0.0'} + requiresBuild: true + dependencies: + '@mapbox/node-pre-gyp': 1.0.11 + node-addon-api: 5.1.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + /binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -1426,6 +1537,13 @@ packages: - supports-color dev: false + /brace-expansion@1.1.12: + resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: false + /braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1445,6 +1563,10 @@ packages: update-browserslist-db: 1.2.3(browserslist@4.28.1) dev: true + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -1514,15 +1636,33 @@ packages: fsevents: 2.3.3 dev: true + /chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + dev: false + + /color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + dev: false + /commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} dev: true + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: false + /confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} dev: true + /console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + dev: false + /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -1599,7 +1739,6 @@ packages: optional: true dependencies: ms: 2.1.3 - dev: true /decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} @@ -1612,6 +1751,10 @@ packages: type-detect: 4.1.0 dev: true + /delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dev: false + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1622,6 +1765,11 @@ packages: engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} dev: false + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dev: false + /didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} dev: true @@ -1635,6 +1783,11 @@ packages: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} dev: true + /dotenv@16.6.1: + resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + engines: {node: '>=12'} + dev: false + /dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1644,6 +1797,12 @@ packages: gopd: 1.2.0 dev: false + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -1652,6 +1811,10 @@ packages: resolution: {integrity: sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw==} dev: true + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: false + /encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -1884,6 +2047,17 @@ packages: engines: {node: '>= 0.6'} dev: false + /fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + dev: false + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: false + /fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1895,6 +2069,22 @@ packages: /function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + /gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + deprecated: This package is no longer supported. + dependencies: + aproba: 2.1.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + dev: false + /gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -1953,6 +2143,18 @@ packages: is-glob: 4.0.3 dev: true + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: false + /gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -1963,6 +2165,10 @@ packages: engines: {node: '>= 0.4'} dev: false + /has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + dev: false + /hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1980,6 +2186,16 @@ packages: toidentifier: 1.0.1 dev: false + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + dev: false + /human-signals@5.0.0: resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} engines: {node: '>=16.17.0'} @@ -1992,6 +2208,14 @@ packages: safer-buffer: 2.1.2 dev: false + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: false + /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} dev: false @@ -2020,6 +2244,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: false + /is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} @@ -2073,6 +2302,37 @@ packages: hasBin: true dev: true + /jsonwebtoken@9.0.3: + resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} + engines: {node: '>=12', npm: '>=6'} + 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.7.3 + dev: false + + /jwa@2.0.1: + resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@4.0.1: + resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==} + dependencies: + jwa: 2.0.1 + safe-buffer: 5.2.1 + dev: false + /lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2090,6 +2350,34 @@ packages: pkg-types: 1.3.1 dev: true + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: false + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false + + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /loose-envify@1.4.0: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true @@ -2115,6 +2403,13 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + dependencies: + semver: 6.3.1 + dev: false + /math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2174,6 +2469,38 @@ packages: engines: {node: '>=12'} dev: true + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.12 + dev: false + + /minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: false + + /minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + dev: false + + /minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + dev: false + + /mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + dev: false + /mlly@1.8.0: resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==} dependencies: @@ -2209,10 +2536,34 @@ packages: engines: {node: '>= 0.6'} dev: false + /node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + dev: false + + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + dev: false + /node-releases@2.0.27: resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} dev: true + /nopt@5.0.0: + resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} + engines: {node: '>=6'} + hasBin: true + dependencies: + abbrev: 1.1.1 + dev: false + /normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -2225,6 +2576,16 @@ packages: path-key: 4.0.0 dev: true + /npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + deprecated: This package is no longer supported. + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + dev: false + /object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2246,6 +2607,12 @@ packages: ee-first: 1.1.1 dev: false + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: false + /onetime@6.0.0: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} @@ -2265,6 +2632,11 @@ packages: engines: {node: '>= 0.8'} dev: false + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: false + /path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2295,6 +2667,65 @@ packages: resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} dev: true + /pg-cloudflare@1.3.0: + resolution: {integrity: sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==} + requiresBuild: true + dev: false + optional: true + + /pg-connection-string@2.10.1: + resolution: {integrity: sha512-iNzslsoeSH2/gmDDKiyMqF64DATUCWj3YJ0wP14kqcsf2TUklwimd+66yYojKwZCA7h2yRNLGug71hCBA2a4sw==} + dev: false + + /pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + /pg-pool@3.11.0(pg@8.17.2): + resolution: {integrity: sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==} + peerDependencies: + pg: '>=8.0' + dependencies: + pg: 8.17.2 + dev: false + + /pg-protocol@1.11.0: + resolution: {integrity: sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==} + + /pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + /pg@8.17.2: + resolution: {integrity: sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + dependencies: + pg-connection-string: 2.10.1 + pg-pool: 3.11.0(pg@8.17.2) + pg-protocol: 1.11.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.3.0 + dev: false + + /pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + dependencies: + split2: 4.2.0 + dev: false + /picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} dev: true @@ -2403,6 +2834,24 @@ packages: source-map-js: 1.2.1 dev: true + /postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + /postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + /postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + /postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + dependencies: + xtend: 4.0.2 + /pretty-format@29.7.0: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2509,6 +2958,15 @@ packages: pify: 2.3.0 dev: true + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: false + /readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2535,6 +2993,14 @@ packages: engines: {iojs: '>=1.0.0', node: '>=0.10.0'} dev: true + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + dependencies: + glob: 7.2.3 + dev: false + /rollup@4.56.0: resolution: {integrity: sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -2593,7 +3059,12 @@ packages: /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true - dev: true + + /semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + dev: false /send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} @@ -2628,6 +3099,10 @@ packages: - supports-color dev: false + /set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + dev: false + /setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} dev: false @@ -2688,6 +3163,10 @@ packages: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} dev: true + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: false + /signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -2698,6 +3177,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: false @@ -2715,6 +3199,28 @@ packages: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} dev: true + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: false + + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: false + /strip-final-newline@3.0.0: resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} engines: {node: '>=12'} @@ -2777,6 +3283,19 @@ packages: - yaml dev: true + /tar@6.2.1: + resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} + engines: {node: '>=10'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + dev: false + /thenify-all@1.6.0: resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} engines: {node: '>=0.8'} @@ -2824,6 +3343,10 @@ packages: engines: {node: '>=0.6'} dev: false + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + dev: false + /ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} dev: true @@ -2952,7 +3475,6 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true /utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} @@ -3082,6 +3604,17 @@ packages: - terser dev: true + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + dev: false + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + dev: false + /which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -3099,6 +3632,16 @@ packages: stackback: 0.0.2 dev: true + /wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + dependencies: + string-width: 4.2.3 + dev: false + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: false + /xmlbuilder2@3.1.1: resolution: {integrity: sha512-WCSfbfZnQDdLQLiMdGUQpMxxckeQ4oZNMNhLVkcekTu7xhD4tuUDyAPoY8CwXvBYE6LwBHd6QW2WZXlOWr1vCw==} engines: {node: '>=12.0'} @@ -3109,10 +3652,18 @@ packages: js-yaml: 3.14.1 dev: false + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + /yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} dev: true + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: false + /yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'}