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 @@ -95,5 +95,6 @@
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
},
"packageManager": "pnpm@9.15.5+sha512.845196026aab1cc3f098a0474b64dfbab2afe7a1b4e91dd86895d8e4aa32a7a6d03049e2d0ad770bbe4de023a7122fb68c1a1d6e0d033c7076085f9d5d4800d4"
}
9 changes: 8 additions & 1 deletion backend/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { User } from '../users/entities/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { VerifyChallengeDto } from './dto/verify-challenge.dto';
import { RateLimitService } from './rate-limit.service';

const mockAuthService = () => ({
generateChallenge: jest
Expand All @@ -22,7 +23,13 @@ describe('AuthController', () => {
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [{ provide: AuthService, useValue: mockAuthService() }],
providers: [
{ provide: AuthService, useValue: mockAuthService() },
{
provide: RateLimitService,
useValue: { getRateLimitStatus: jest.fn() },
},
],
}).compile();

controller = module.get<AuthController>(AuthController);
Expand Down
42 changes: 39 additions & 3 deletions backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { GenerateChallengeDto } from './dto/generate-challenge.dto';
import { VerifyChallengeDto } from './dto/verify-challenge.dto';
import { VerifyWalletDto } from './dto/verify-wallet.dto';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { RateLimitStatusDto } from './dto/rate-limit-status.dto';
import {
ApiBearerAuth,
ApiOperation,
ApiResponse,
ApiTags,
} from '@nestjs/swagger';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import { User } from '../users/entities/user.entity';

@ApiTags('Auth')
@Throttle({ default: { limit: 10, ttl: 60000 } })
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
constructor(
private readonly authService: AuthService,
private readonly rateLimitService: RateLimitService,
) {}

@Post('challenge')
@HttpCode(HttpStatus.OK)
Expand Down Expand Up @@ -42,4 +61,21 @@ export class AuthController {
);
return { verified };
}

@Get('rate-limit')
@ApiBearerAuth()
@ApiOperation({
summary: 'Get current rate limit status for authenticated user',
})
@ApiResponse({
status: 200,
description: 'Current rate limit status',
type: RateLimitStatusDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized' })
async getRateLimitStatus(
@CurrentUser() user: User,
): Promise<RateLimitStatusDto> {
return this.rateLimitService.getStatus(user.id);
}
}
12 changes: 12 additions & 0 deletions backend/src/auth/auth.e2e.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { AuthService } from './auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { RateLimitService } from './rate-limit.service';
import { ThrottlerModule } from '@nestjs/throttler';

const sign = (kp: Keypair, text: string): string =>
kp.sign(Buffer.from(text, 'utf-8')).toString('hex');
Expand Down Expand Up @@ -57,6 +59,16 @@ describe('Auth E2E — challenge → verify flow', () => {
{ provide: JwtService, useValue: mockJwtService },
JwtStrategy,
Reflector,
{
provide: RateLimitService,
useValue: {
getRateLimitStatus: jest.fn().mockResolvedValue({
limit: 100,
remaining: 99,
reset_at: new Date(),
}),
},
},
{
provide: ConfigService,
useValue: {
Expand Down
5 changes: 4 additions & 1 deletion backend/src/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { User } from '../users/entities/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { RateLimitService } from './rate-limit.service';
import { JwtStrategy } from './strategies/jwt.strategy';

@Module({
imports: [
PassportModule,
ConfigModule,
TypeOrmModule.forFeature([User]),
ThrottlerModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
Expand All @@ -25,7 +28,7 @@ import { JwtStrategy } from './strategies/jwt.strategy';
}),
],
controllers: [AuthController],
providers: [AuthService, JwtStrategy],
providers: [AuthService, JwtStrategy, RateLimitService],
exports: [AuthService, JwtModule],
})
export class AuthModule {}
21 changes: 21 additions & 0 deletions backend/src/auth/dto/rate-limit-status.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ApiProperty } from '@nestjs/swagger';

export class RateLimitStatusDto {
@ApiProperty({
description: 'Maximum number of requests allowed in the window',
example: 100,
})
limit: number;

@ApiProperty({
description: 'Number of requests remaining in the current window',
example: 87,
})
remaining: number;

@ApiProperty({
description: 'When the rate limit window resets',
example: '2026-03-30T04:00:00.000Z',
})
reset_at: Date;
}
43 changes: 43 additions & 0 deletions backend/src/auth/rate-limit.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerStorage } from '@nestjs/throttler';
import { RateLimitStatusDto } from './dto/rate-limit-status.dto';

/** Default throttler config mirrors the global ThrottlerModule config in AppModule */
const DEFAULT_LIMIT = 100;
const DEFAULT_TTL_MS = 60_000; // 60 seconds

@Injectable()
export class RateLimitService {
constructor(private readonly throttlerStorage: ThrottlerStorage) {}

/**
* Returns the current rate-limit status for the given identifier
* (typically the user id or IP address).
*
* @param identifier - unique key used by ThrottlerStorage (user id)
*/
async getStatus(identifier: string): Promise<RateLimitStatusDto> {
const key = `throttle:default:${identifier}`;

let used = 0;
try {
const record = await this.throttlerStorage.increment(
key,
DEFAULT_TTL_MS,
DEFAULT_LIMIT,
DEFAULT_LIMIT,
'default',
);
// increment returns { totalHits, timeToExpire }
used = record.totalHits;
} catch {
// If storage doesn't have a record yet, treat as 0 hits
used = 0;
}

const remaining = Math.max(0, DEFAULT_LIMIT - used);
const reset_at = new Date(Date.now() + DEFAULT_TTL_MS);

return { limit: DEFAULT_LIMIT, remaining, reset_at };
}
}
42 changes: 42 additions & 0 deletions backend/src/health/dto/detailed-health.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';

export class DatabaseStatusDto {
@ApiProperty({ example: 'up' })
status: string;

@ApiProperty({ example: 4 })
latency_ms: number;
}

export class SorobanStatusDto {
@ApiProperty({ example: 'up' })
status: string;

@ApiProperty({ example: 120 })
latency_ms: number;
}

export class CacheStatusDto {
@ApiProperty({ example: 'up' })
status: string;

@ApiProperty({ example: 0.85 })
hit_rate: number;
}

export class DetailedHealthDto {
@ApiProperty({ enum: ['healthy', 'degraded', 'down'], example: 'healthy' })
status: 'healthy' | 'degraded' | 'down';

@ApiProperty({ type: DatabaseStatusDto })
database: DatabaseStatusDto;

@ApiProperty({ type: SorobanStatusDto })
soroban: SorobanStatusDto;

@ApiProperty({ type: CacheStatusDto })
cache: CacheStatusDto;

@ApiProperty({ example: 3600 })
uptime_seconds: number;
}
13 changes: 13 additions & 0 deletions backend/src/health/health.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HealthCheckResult } from '@nestjs/terminus';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { HealthService } from './health.service';
import { DetailedHealthDto } from './dto/detailed-health.dto';

@ApiTags('Health')
@Controller('health')
Expand Down Expand Up @@ -34,4 +35,16 @@ export class HealthController {
checkPing() {
return this.healthService.checkPing();
}

@Get('detailed')
@Public()
@ApiOperation({ summary: 'Detailed health status for monitoring' })
@ApiResponse({
status: 200,
description: 'Detailed health status of all components',
type: DetailedHealthDto,
})
async checkDetailed(): Promise<DetailedHealthDto> {
return this.healthService.checkDetailed();
}
}
76 changes: 76 additions & 0 deletions backend/src/health/health.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import {
import { InjectDataSource } from '@nestjs/typeorm';
import * as os from 'os';
import { DataSource } from 'typeorm';
import { DetailedHealthDto } from './dto/detailed-health.dto';

const START_TIME = Date.now();

@Injectable()
export class HealthService {
Expand Down Expand Up @@ -61,4 +64,77 @@ export class HealthService {
timestamp: new Date().toISOString(),
};
}

/**
* Detailed health check with individual component status and latency for monitoring.
* Checks database connectivity, Soroban RPC reachability, and cache status.
*/
async checkDetailed(): Promise<DetailedHealthDto> {
const [dbResult, sorobanResult] = await Promise.all([
this.checkDatabase(),
this.checkSoroban(),
]);

const overallStatus =
dbResult.status === 'down'
? 'down'
: dbResult.status === 'degraded' || sorobanResult.status === 'degraded'
? 'degraded'
: 'healthy';

return {
status: overallStatus,
database: dbResult,
soroban: sorobanResult,
cache: this.getCacheStatus(),
uptime_seconds: Math.floor((Date.now() - START_TIME) / 1000),
};
}

private async checkDatabase(): Promise<{
status: string;
latency_ms: number;
}> {
const start = Date.now();
try {
await this.dataSource.query('SELECT 1');
return { status: 'up', latency_ms: Date.now() - start };
} catch {
return { status: 'down', latency_ms: Date.now() - start };
}
}

private async checkSoroban(): Promise<{
status: string;
latency_ms: number;
}> {
const rpcUrl =
process.env.SOROBAN_RPC_URL ?? 'https://soroban-testnet.stellar.org';
const start = Date.now();
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const response = await fetch(rpcUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'getHealth',
params: [],
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
const latency = Date.now() - start;
return { status: response.ok ? 'up' : 'degraded', latency_ms: latency };
} catch {
return { status: 'down', latency_ms: Date.now() - start };
}
}

/** Cache is in-memory (challenge cache); always 'up'. Hit rate is not tracked externally. */
private getCacheStatus(): { status: string; hit_rate: number } {
return { status: 'up', hit_rate: 0 };
}
}
28 changes: 28 additions & 0 deletions backend/src/markets/entities/user-bookmark.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
ManyToOne,
Index,
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { Market } from './market.entity';

@Entity('user_bookmarks')
@Index(['user', 'market'], { unique: true })
export class UserBookmark {
@PrimaryGeneratedColumn('uuid')
id: string;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'user_id' })
user: User;

@ManyToOne(() => Market, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'market_id' })
market: Market;

@CreateDateColumn()
created_at: Date;
}
Loading
Loading