From f20a7e0b4a9856294150c7f03780b089ed5417c5 Mon Sep 17 00:00:00 2001 From: aniokedianne <278065276+aniokedianne@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:45 +0100 Subject: [PATCH 1/2] feat: add X-RateLimit response headers to throttle guard Closes #417 Closes #418 Closes #419 Closes #420 --- src/common/guards/throttle.guard.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/common/guards/throttle.guard.ts b/src/common/guards/throttle.guard.ts index 7bdef9b..ad845de 100644 --- a/src/common/guards/throttle.guard.ts +++ b/src/common/guards/throttle.guard.ts @@ -6,7 +6,7 @@ import { HttpStatus, OnModuleDestroy, } from '@nestjs/common'; -import { Request } from 'express'; +import { Request, Response } from 'express'; interface RequestWindow { count: number; @@ -44,7 +44,9 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy { } canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); + const http = context.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); const ip = this.extractIP(request); const now = Date.now(); @@ -56,6 +58,7 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy { // for the cleanup sweep above. this.requests.delete(ip); this.requests.set(ip, { count: 1, windowStart: now }); + this.setRateLimitHeaders(response, 1, now); return true; } @@ -63,6 +66,11 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy { const retryAfter = Math.ceil( (window.windowStart + this.TIME_WINDOW_MS - now) / 1000, ); + const resetAt = Math.ceil((window.windowStart + this.TIME_WINDOW_MS) / 1000); + response.setHeader('X-RateLimit-Limit', this.MAX_REQUESTS); + response.setHeader('X-RateLimit-Remaining', 0); + response.setHeader('X-RateLimit-Reset', resetAt); + response.setHeader('Retry-After', retryAfter); throw new HttpException( { statusCode: HttpStatus.TOO_MANY_REQUESTS, @@ -77,9 +85,21 @@ export class ThrottleGuard implements CanActivate, OnModuleDestroy { } window.count++; + this.setRateLimitHeaders(response, window.count, window.windowStart); return true; } + private setRateLimitHeaders( + response: Response, + used: number, + windowStart: number, + ): void { + const resetAt = Math.ceil((windowStart + this.TIME_WINDOW_MS) / 1000); + response.setHeader('X-RateLimit-Limit', this.MAX_REQUESTS); + response.setHeader('X-RateLimit-Remaining', Math.max(0, this.MAX_REQUESTS - used)); + response.setHeader('X-RateLimit-Reset', resetAt); + } + private extractIP(request: Request): string { const forwarded = request.headers['x-forwarded-for']; if (typeof forwarded === 'string') { From 9b5284039936e72b51196d1b0bfb158f24c192f4 Mon Sep 17 00:00:00 2001 From: Chucks1093 Date: Wed, 26 Aug 2026 12:33:51 +0100 Subject: [PATCH 2/2] feat: queue depth health check, auth Swagger docs, request logging, migration CI Closes #421 Closes #422 Closes #423 Closes #424 --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++++ src/app.module.ts | 5 +++++ src/auth/auth.controller.ts | 19 +++++++++++++++++-- src/health/health.controller.ts | 13 +++++++++++++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 931ea9a..e7a72d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,3 +30,35 @@ jobs: - name: Unit tests run: npx jest --ci + + migrate: + name: Migration test + runs-on: ubuntu-latest + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: parashield_test + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Run database migrations + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/parashield_test + run: npx prisma migrate deploy diff --git a/src/app.module.ts b/src/app.module.ts index 7f89ad1..64ddb68 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -13,6 +13,7 @@ import { AuthModule } from './auth/auth.module'; import { HealthModule } from './health/health.module'; import { RedisModule } from './redis/redis.module'; import { VersioningInterceptor } from './common/interceptors/versioning.interceptor'; +import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; import { WebhooksModule } from './common/webhooks/webhooks.module'; /** @@ -100,6 +101,10 @@ function validateConfig(config: Record) { provide: APP_GUARD, useClass: ThrottlerGuard, }, + { + provide: APP_INTERCEPTOR, + useClass: LoggingInterceptor, + }, { provide: APP_INTERCEPTOR, useClass: VersioningInterceptor, diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index b5f6420..c55a5ed 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -34,9 +34,16 @@ export class AuthController { */ @Get('challenge') @Throttle(AUTH_THROTTLE) - @ApiOperation({ summary: 'Obtain a server-issued nonce before login' }) + @ApiOperation({ + summary: 'Obtain a server-issued nonce before login', + description: + 'Step 1 of 2 in the wallet-based auth flow. Returns a cryptographically random ' + + 'nonce tied to the given wallet address. The nonce expires in 5 minutes and must ' + + 'be signed by the wallet private key, then submitted to POST /auth/login.', + }) @ApiResponse({ status: 200, description: 'Returns the challenge nonce' }) @ApiResponse({ status: 400, description: 'Invalid wallet address' }) + @ApiResponse({ status: 429, description: 'Too many requests — rate limit exceeded (10 req / 60 s)' }) async getChallenge(@Query('wallet') wallet: string) { if (!wallet || !/^G[A-Z2-7]{55}$/.test(wallet)) { throw new UnauthorizedException('Invalid or missing Stellar wallet address'); @@ -73,10 +80,18 @@ export class AuthController { @Post('login') @HttpCode(HttpStatus.OK) @Throttle(AUTH_THROTTLE) - @ApiOperation({ summary: 'Authenticate with a Stellar wallet signature and receive a JWT' }) + @ApiOperation({ + summary: 'Authenticate with a Stellar wallet signature and receive a JWT', + description: + 'Step 2 of 2 in the wallet-based auth flow. Sign the nonce obtained from ' + + 'GET /auth/challenge with your Stellar private key (Ed25519), base64-encode the ' + + 'signature, and submit it here. On success, returns a signed JWT to use as ' + + 'Bearer token in subsequent authenticated requests.', + }) @ApiBody({ type: WalletLoginDto }) @ApiResponse({ status: 200, description: 'Returns a JWT token for the authenticated wallet' }) @ApiResponse({ status: 401, description: 'Invalid or missing wallet signature' }) + @ApiResponse({ status: 429, description: 'Too many requests — rate limit exceeded (10 req / 60 s)' }) async login(@Body() dto: WalletLoginDto) { const { walletAddress, signature, message } = dto; diff --git a/src/health/health.controller.ts b/src/health/health.controller.ts index ecd0b90..38fec86 100644 --- a/src/health/health.controller.ts +++ b/src/health/health.controller.ts @@ -85,12 +85,24 @@ export class HealthController { // stop processing without any observable API-layer error. A PING here // surfaces the failure in the health endpoint so load balancers and // on-call alerts can react before users notice stuck claims or policies. + let queueDepths: Record | undefined; try { const pong = await this.redis.ping(); if (pong !== 'PONG') { queueStatus = 'error'; queueError = `Redis PING returned unexpected response: ${pong}`; this.logger.error(`Health check: ${queueError}`); + } else { + // #421 — Report waiting job counts for known Bull queues so ops can + // detect build-up before processing latency becomes user-visible. + const queueNames = (this.config.get('HEALTH_QUEUE_NAMES') ?? 'claims,oracle') + .split(',') + .map(n => n.trim()) + .filter(Boolean); + const depths = await Promise.all( + queueNames.map(async (name) => [name, await this.redis.llen(`bull:${name}:wait`)] as [string, number]), + ); + queueDepths = Object.fromEntries(depths); } } catch (err) { queueStatus = 'error'; @@ -116,6 +128,7 @@ export class HealthController { }, queue: { status: queueStatus, + ...(queueDepths !== undefined ? { depth: queueDepths } : {}), ...(queueError ? { error: queueError } : {}), }, },