Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.js",
"check:migrations:down": "node ./scripts/check-migrations-down.js",
"test:rollback": "bash ./scripts/test-rollback.sh"
"test:rollback": "bash ./scripts/test-rollback.sh",
"generate:openapi": "ts-node -r tsconfig-paths/register scripts/generate-openapi-spec.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1019.0",
Expand Down
72 changes: 72 additions & 0 deletions backend/scripts/generate-openapi-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* generate-openapi-spec.ts
*
* Bootstraps the NestJS app without listening on a port and dumps the
* OpenAPI document to JSON and YAML files.
*
* Usage:
* npx ts-node -r tsconfig-paths/register scripts/generate-openapi-spec.ts
*
* Output:
* openapi.json — machine-readable spec (import into Postman, Insomnia, etc.)
* openapi.yaml — human-readable spec
*/

import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { writeFileSync } from 'fs';
import { join } from 'path';
import { AppModule } from '../src/app.module';
import { ValidationPipe } from '@nestjs/common';
import { PageDto } from '../src/common/dto/page.dto';
import { PageMetaDto } from '../src/common/dto/page-meta.dto';
import { TransactionResponseDto } from '../src/modules/transactions/dto/transaction-response.dto';

async function generate() {
const app = await NestFactory.create(AppModule, { logger: false });

app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));

const config = new DocumentBuilder()
.setTitle('Nestera API')
.setDescription(
'Nestera is a decentralized savings & investment platform on Stellar. ' +
'All amounts are in USDC (7 decimal places).',
)
.setVersion('2')
.setContact('Nestera Team', 'https://github.com/Devsol-01/Nestera', 'support@nestera.io')
.setLicense('MIT', 'https://opensource.org/licenses/MIT')
.addServer('http://localhost:3001', 'Local development')
.addServer('https://api.nestera.io', 'Production')
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
'access-token',
)
.build();

const document = SwaggerModule.createDocument(app, config, {
extraModels: [PageDto, PageMetaDto, TransactionResponseDto],
});

const outDir = join(__dirname, '..');

// JSON
const jsonPath = join(outDir, 'openapi.json');
writeFileSync(jsonPath, JSON.stringify(document, null, 2), 'utf-8');
console.log(`✅ OpenAPI JSON written to ${jsonPath}`);

// YAML (built-in js-yaml is available via @nestjs/swagger)
// eslint-disable-next-line @typescript-eslint/no-require-imports
const yaml = require('js-yaml') as typeof import('js-yaml');
const yamlPath = join(outDir, 'openapi.yaml');
writeFileSync(yamlPath, yaml.dump(document, { lineWidth: 120 }), 'utf-8');
console.log(`✅ OpenAPI YAML written to ${yamlPath}`);

await app.close();
}

generate().catch((err) => {
console.error('Failed to generate OpenAPI spec:', err);
process.exit(1);
});
6 changes: 5 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ import { CacheStrategyService } from '../modules/cache/cache-strategy.service';
import { AuthRateLimitService } from './services/auth-rate-limit.service';
import { AuthRateLimitGuard } from './guards/auth-rate-limit.guard';
import { AuthSecurityAdminController } from './controllers/auth-security-admin.controller';
import { AuditLog } from '../common/entities/audit-log.entity';
import { AuditLogService } from '../common/services/audit-log.service';

@Module({
imports: [
UserModule,
TypeOrmModule.forFeature([User, RefreshToken, Session]),
TypeOrmModule.forFeature([User, RefreshToken, Session, AuditLog]),
CacheModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
Expand All @@ -45,13 +47,15 @@ import { AuthSecurityAdminController } from './controllers/auth-security-admin.c
CacheStrategyService,
AuthRateLimitService,
AuthRateLimitGuard,
AuditLogService,
],
exports: [
AuthService,
TwoFactorService,
JwtModule,
PassportModule,
AuthRateLimitService,
AuditLogService,
],
})
export class AuthModule {}
43 changes: 42 additions & 1 deletion backend/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
UnauthorizedException,
BadRequestException,
Inject,
Optional,
forwardRef,
Logger,
} from '@nestjs/common';
Expand All @@ -18,6 +19,11 @@ import {
LinkWalletDto,
RefreshTokenDto,
} from './dto/auth.dto';
import { AuditLogService } from '../common/services/audit-log.service';
import {
AuditAction,
AuditResourceType,
} from '../common/entities/audit-log.entity';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import * as bcrypt from 'bcrypt';
Expand Down Expand Up @@ -54,6 +60,7 @@ export class AuthService {
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly configService: ConfigService,
@Optional() private readonly auditLogService?: AuditLogService,
) {}

async register(dto: RegisterDto, ip?: string, userAgent?: string) {
Expand Down Expand Up @@ -112,6 +119,16 @@ export class AuthService {
if (!user) {
// Record failed attempt
await this.handleFailedLogin(dto.email, ip);
void this.auditLogService?.log({
action: AuditAction.LOGIN,
actor: dto.email,
resourceType: AuditResourceType.USER,
success: false,
errorMessage: 'Invalid credentials',
ipAddress: ip,
userAgent,
description: 'User login failed - invalid credentials',
});
throw new UnauthorizedException('Invalid credentials');
}

Expand Down Expand Up @@ -161,6 +178,17 @@ export class AuthService {
// Update last login
await this.userService.update(user.id, { lastLoginAt: new Date() });

void this.auditLogService?.log({
action: AuditAction.LOGIN,
actor: user.email,
resourceType: AuditResourceType.USER,
resourceId: user.id,
success: true,
ipAddress: ip,
userAgent,
description: 'User login successful',
});

return {
accessToken,
refreshToken: refreshToken.token,
Expand Down Expand Up @@ -732,7 +760,20 @@ export class AuthService {
{ userId, isRevoked: false },
{ isRevoked: true },
);
return result.affected || 0;
const count = result.affected || 0;

if (count > 0) {
void this.auditLogService?.log({
action: AuditAction.LOGOUT,
actor: userId,
resourceType: AuditResourceType.USER,
resourceId: userId,
success: true,
description: `User logged out — ${count} session(s) revoked`,
});
}

return count;
}

async revokeUserSessionsByDevice(
Expand Down
8 changes: 6 additions & 2 deletions backend/src/common/common.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PiiEncryptionService } from './services/pii-encryption.service';
import { RateLimitMonitorService } from './services/rate-limit-monitor.service';
import { AuditLogService } from './services/audit-log.service';
import { AuditLog } from './entities/audit-log.entity';

@Global()
@Module({
providers: [RateLimitMonitorService, PiiEncryptionService],
exports: [RateLimitMonitorService, PiiEncryptionService],
imports: [TypeOrmModule.forFeature([AuditLog])],
providers: [RateLimitMonitorService, PiiEncryptionService, AuditLogService],
exports: [RateLimitMonitorService, PiiEncryptionService, AuditLogService],
})
export class CommonModule {}
22 changes: 22 additions & 0 deletions backend/src/common/decorators/audit-log.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { SetMetadata } from '@nestjs/common';
import { AuditAction, AuditResourceType } from '../entities/audit-log.entity';

export const AUDIT_LOG_METADATA = 'audit_log_metadata';

export interface AuditLogOptions {
action?: AuditAction;
resourceType?: AuditResourceType;
description?: string;
}

/**
* Decorator for explicitly marking a controller method for audit logging.
* The AuditLogInterceptor reads this metadata to override inferred values.
*
* @example
* @AuditLog({ action: AuditAction.APPROVE, resourceType: AuditResourceType.CLAIM, description: 'Admin approved claim' })
* @Post(':id/approve')
* async approveClaim(@Param('id') id: string) { ... }
*/
export const AuditLog = (options: AuditLogOptions = {}) =>
SetMetadata(AUDIT_LOG_METADATA, options);
Loading
Loading