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
25 changes: 25 additions & 0 deletions src/common/decorators/api-paginated.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';
import { PaginatedResponseDto } from '../dto/paginated-response.dto';

export function ApiPaginatedResponse<TModel extends Type>(model: TModel) {
return applyDecorators(
ApiExtraModels(PaginatedResponseDto, model),
ApiOkResponse({
description: 'Paginated list of resources',
schema: {
allOf: [
{ $ref: getSchemaPath(PaginatedResponseDto) },
{
properties: {
data: {
type: 'array',
items: { $ref: getSchemaPath(model) },
},
},
},
],
},
}),
);
}
25 changes: 25 additions & 0 deletions src/common/dto/paginated-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';

export class PaginationMetaDto {
@ApiProperty({ description: 'Number of items per page', example: 20 })
limit: number;

@ApiProperty({ description: 'Number of items skipped', example: 0 })
offset: number;

@ApiProperty({ description: 'Total number of items matching the query', example: 42 })
total: number;
}

export class PaginatedResponseDto<T> {
@ApiProperty({ description: 'Indicates whether the request was successful', example: true })
success: boolean;

data: T[];

@ApiProperty({ type: PaginationMetaDto })
pagination: PaginationMetaDto;

@ApiProperty({ example: 'Resources retrieved successfully' })
message: string;
}
73 changes: 62 additions & 11 deletions src/config/swagger.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,74 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as fs from 'fs';
import * as path from 'path';

function readPackageVersion(): string {
try {
const pkg = JSON.parse(
fs.readFileSync(path.resolve(process.cwd(), 'package.json'), 'utf-8'),
);
return pkg.version || '1.0';
} catch {
return '1.0';
}
}

export function setupSwagger(app: INestApplication): void {
const configService = app.get(ConfigService);
const swaggerEnabled = configService.get<string>('SWAGGER_ENABLED', 'true');

if (swaggerEnabled === 'false') {
return;
}

const version = readPackageVersion();

const config = new DocumentBuilder()
.setTitle('StepFi API')
.setDescription('Off-chain orchestration layer for learner BNPL on Stellar')
.setVersion('1.0')
.setDescription(
'Off-chain orchestration layer for learner BNPL (Buy Now, Pay Later) on Stellar.\n\n'
+ 'This API manages wallet-based authentication, loan lifecycle, reputation scoring, liquidity pools, '
+ 'vendor registry, mentor vouching, and blockchain transaction submission.\n\n'
+ '## Authentication\n'
+ '- **JWT Bearer Token** — Most endpoints require a valid JWT obtained via `POST /auth/verify`.\n'
+ '- **X-API-Key** — Reserved for admin/vendor service-to-service calls (not yet implemented).',
)
.setVersion(version)
.addBearerAuth()
.addTag('auth', 'Wallet-based authentication')
.addApiKey(
{
type: 'apiKey',
in: 'header',
name: 'X-API-Key',
description: 'API key for service-to-service authentication',
},
'ApiKey-auth',
)
.addTag('auth', 'Wallet-based authentication (nonce, verify, refresh, register)')
.addTag('users', 'User profile management')
.addTag('loans', 'Loan lifecycle management')
.addTag('reputation', 'On-chain reputation scoring')
.addTag('liquidity', 'Liquidity pool operations')
.addTag('learners', 'Learner-specific profile and educational details')
.addTag('loans', 'Loan lifecycle — quote, create, repay, list, available credit')
.addTag('reputation', 'On-chain reputation scoring and credit tiers')
.addTag('liquidity', 'Liquidity pool operations — overview, deposit, withdraw')
.addTag('sponsors', 'Sponsor registration and pool management')
.addTag('vendors', 'Learning vendor registry')
.addTag('transactions', 'Transaction submission and tracking')
.addTag('notifications', 'User notifications')
.addTag('health', 'Health check')
.addTag('vouching', 'Mentor vouching system for credit limit boosts')
.addTag('transactions', 'Stellar transaction submission and status tracking')
.addTag('notifications', 'User notifications — list, read, mark as read')
.addTag('health', 'System health checks (database, Horizon, indexer, Redis, BullMQ)')
.build();

const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api/v1/docs', app, document);
const document = SwaggerModule.createDocument(app, config, { deepScanRoutes: true });

SwaggerModule.setup(`${configService.get('API_PREFIX', 'api/v1')}/docs`, app, document, {
swaggerOptions: {
persistAuthorization: true,
displayRequestDuration: true,
filter: true,
docExpansion: 'list',
},
customSiteTitle: 'StepFi API Documentation',
});
}
44 changes: 44 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,50 @@ async function bootstrap() {

setupSwagger(app);

const isProduction = process.env.NODE_ENV === 'production';
if (isProduction) {
const docsUsername = process.env.DOCS_USERNAME;
const docsPassword = process.env.DOCS_PASSWORD;

if (docsUsername && docsPassword) {
const docsPaths = [`/${apiPrefix}/docs`, `/${apiPrefix}/docs-json`];

app.getHttpAdapter().getInstance().addHook('preHandler', (request: any, reply: any, done: () => void) => {
if (!docsPaths.includes(request.url)) {
done();
return;
}

const authHeader = request.headers['authorization'];
if (!authHeader || !authHeader.startsWith('Basic ')) {
reply.header('WWW-Authenticate', 'Basic realm="StepFi API Docs"');
reply.status(401).send({ message: 'Documentation requires authentication' });
return;
}

const base64 = authHeader.slice(6);
const decoded = Buffer.from(base64, 'base64').toString('utf-8');
const colonIdx = decoded.indexOf(':');

if (colonIdx === -1) {
reply.status(401).send({ message: 'Invalid authorization header format' });
return;
}

const username = decoded.slice(0, colonIdx);
const password = decoded.slice(colonIdx + 1);

if (username !== docsUsername || password !== docsPassword) {
reply.header('WWW-Authenticate', 'Basic realm="StepFi API Docs"');
reply.status(401).send({ message: 'Invalid credentials' });
return;
}

done();
});
}
}

await app.listen(port, '0.0.0.0');

const logger = app.get(Logger);
Expand Down
1 change: 1 addition & 0 deletions src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@Throttle({ default: { limit: 10, ttl: 60000 } })
@ApiOperation({ summary: 'Refresh access token using refresh token' })
@ApiBody({ schema: { type: 'object', properties: { refreshToken: { type: 'string', description: 'JWT refresh token obtained from POST /auth/verify' } }, required: ['refreshToken'] } })
@ApiResponse({ status: 200, description: 'New tokens issued', type: AuthResponseDto })
@ApiResponse({ status: 401, description: 'Refresh token invalid or expired' })
async refresh(@Body('refreshToken') token: string): Promise<AuthResponseDto> {
Expand Down
3 changes: 2 additions & 1 deletion src/modules/learners/learners.controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Controller, Get, Patch, Body, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger';
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { LearnersService } from './learners.service';
import { UpdateLearnerProfileDto } from './dto/learner-profile.dto';
import { LearnerResponseDto } from './dto/learner-response.dto';
Expand All @@ -25,6 +25,7 @@ export class LearnersController {
@Patch('me')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Update learner profile' })
@ApiBody({ type: UpdateLearnerProfileDto })
@ApiResponse({ status: 200, description: 'Profile updated', type: LearnerResponseDto })
async updateProfile(
@CurrentUser() user: { wallet: string },
Expand Down
40 changes: 27 additions & 13 deletions src/modules/reputation/reputation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,39 @@ import {
Get,
Param,
Request,
UnauthorizedException,
UseGuards,
BadRequestException,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { ReputationService } from './reputation.service';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { ReputationResponseDto } from './dto/reputation-response.dto';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';

@ApiTags('Reputation')
@ApiTags('reputation')
@Controller('reputation')
export class ReputationController {
constructor(private readonly reputationService: ReputationService) { }

@Get(':wallet')
@ApiOperation({ summary: 'Get reputation score for a specific wallet' })
@ApiOperation({ summary: 'Get reputation score for a specific wallet address' })
@ApiParam({
name: 'wallet',
description: 'Stellar wallet address starting with G (56 characters)',
example: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW',
})
@ApiResponse({
status: 200,
description: 'Reputation data retrieved successfully',
type: ReputationResponseDto,
})
@ApiResponse({ status: 400, description: 'Invalid Stellar wallet address format' })
async getScore(@Param('wallet') wallet: string) {
// Validation: Stellar Ed25519 public key format (G + 55 base32 characters)
const stellarWalletRegex = /^G[A-Z2-7]{55}$/;
if (!stellarWalletRegex.test(wallet)) {
throw new BadRequestException({
Expand All @@ -40,17 +54,17 @@ export class ReputationController {
}

@Get('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'Get reputation score for the authenticated user' })
@ApiResponse({
status: 200,
description: 'Reputation data retrieved successfully',
type: ReputationResponseDto,
})
@ApiResponse({ status: 401, description: 'Unauthorized — missing or invalid JWT' })
async getMyScore(@Request() req: any) {
const wallet = req.user?.wallet;

if (!wallet) {
throw new UnauthorizedException({
success: false,
message: 'No authenticated wallet found in request session',
});
}

const data = await this.reputationService.getReputationScore(wallet);

return {
Expand Down
27 changes: 23 additions & 4 deletions test/unit/modules/reputation/reputation.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Test, TestingModule } from '@nestjs/testing';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import { BadRequestException } from '@nestjs/common';
import { ReputationController } from '../../../../src/modules/reputation/reputation.controller';
import { ReputationService } from '../../../../src/modules/reputation/reputation.service';

Expand Down Expand Up @@ -99,9 +99,28 @@ describe('ReputationController', () => {
// GET /reputation/me
// ---------------------------------------------------------------------------
describe('getMyScore', () => {
it('should throw UnauthorizedException since auth guard is not yet wired', async () => {
await expect(controller.getMyScore({})).rejects.toThrow(
UnauthorizedException,
it('should return reputation data for the authenticated user', async () => {
mockReputationService.getReputationScore.mockResolvedValue(mockReputationResponse);

const req = { user: { wallet: validWallet } };
const result = await controller.getMyScore(req);

expect(result).toEqual({
success: true,
data: mockReputationResponse,
message: 'Your reputation data retrieved successfully',
});
expect(reputationService.getReputationScore).toHaveBeenCalledWith(validWallet);
});

it('should propagate service errors to the caller', async () => {
mockReputationService.getReputationScore.mockRejectedValue(
new Error('Contract read failed'),
);

const req = { user: { wallet: validWallet } };
await expect(controller.getMyScore(req)).rejects.toThrow(
'Contract read failed',
);
});
});
Expand Down
Loading