From 2668c3151b433847e6835411ed69e6b38d0b3365 Mon Sep 17 00:00:00 2001 From: Peniel Samuel Date: Wed, 21 Oct 2026 11:26:12 +0000 Subject: [PATCH] feat(puzzles): add deterministic solution checker with rate limiting --- backend/src/app.module.ts | 2 + backend/src/main.ts | 54 +----- .../dto/check-solution.dto.ts | 36 ++++ .../entities/puzzle-answer-attempt.entity.ts | 48 +++++ .../puzzle-solution-checker.controller.ts | 79 ++++++++ .../puzzle-solution-checker.module.ts | 19 ++ .../puzzle-solution-checker.service.spec.ts | 177 ++++++++++++++++++ .../puzzle-solution-checker.service.ts | 159 ++++++++++++++++ 8 files changed, 524 insertions(+), 50 deletions(-) create mode 100644 backend/src/puzzle-solution-checker/dto/check-solution.dto.ts create mode 100644 backend/src/puzzle-solution-checker/entities/puzzle-answer-attempt.entity.ts create mode 100644 backend/src/puzzle-solution-checker/puzzle-solution-checker.controller.ts create mode 100644 backend/src/puzzle-solution-checker/puzzle-solution-checker.module.ts create mode 100644 backend/src/puzzle-solution-checker/puzzle-solution-checker.service.spec.ts create mode 100644 backend/src/puzzle-solution-checker/puzzle-solution-checker.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 8c16fd61..795ab573 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -24,6 +24,7 @@ import { NFTClaimModule } from './nft-claim/nft-claim.module'; import { ProgressModule } from './progress/progress.module'; import { PuzzleDependencyModule } from './puzzle-dependency/puzzle-dependency.module'; import { PuzzleModule } from './puzzle/puzzle.module'; +import { PuzzleSolutionCheckerModule } from './puzzle-solution-checker/puzzle-solution-checker.module'; import { PuzzleSubmissionModule } from './puzzle-submission/puzzle-submission.module'; import { PuzzleTranslationModule } from './puzzle-translation/puzzle-translation.module'; import { ReportsModule } from './reports/reports.module'; @@ -68,6 +69,7 @@ import { UserReportCardModule } from './user-report-card/user-report-card.module ProgressModule, PuzzleDependencyModule, PuzzleModule, + PuzzleSolutionCheckerModule, PuzzleSubmissionModule, PuzzleTranslationModule, ReportsModule, diff --git a/backend/src/main.ts b/backend/src/main.ts index 8a946cd2..76abe268 100644 --- a/backend/src/main.ts +++ b/backend/src/main.ts @@ -1,32 +1,6 @@ -import { Module } from "@nestjs/common" -import { AppController } from "./app.controller" -import { AppService } from "./app.service" -import { TypeOrmModule } from "@nestjs/typeorm" -import { ConfigModule, ConfigService } from "@nestjs/config" -import { AuthModule } from "./auth/auth.module" -import { UserInventoryModule } from "./user-inventory/user-inventory.module" -import { CacheModule } from "./cache/cache.module" -import appConfig from "config/app.config" -import databaseConfig from "config/database.config" -import { PuzzleCategoryModule } from "./puzzle-category/puzzle-category.module" -import { RewardsModule } from "./rewards/rewards.module" -import { PuzzleModule } from "./puzzle/puzzle.module" -import { PuzzleSubmissionModule } from "./puzzle-submission/puzzle-submission.module" -import { ContentModule } from "./content/content.module" -import { UserReportCardModule } from "./user-report-card/user-report-card.module" -import { PuzzleDependencyModule } from "./puzzle-dependency/puzzle-dependency.module" -import { TimeTrialModule } from "./time-trial/time-trial.module" -import { InAppNotificationsModule } from "./in-app-notifications/in-app-notifications.module" -import { User } from "./auth/entities/user.entity" -import { TimeTrial } from "./time-trial/time-trial.entity" -import { Puzzle } from "./puzzle/puzzle.entity" -import { Category } from "./puzzle-category/entities/category.entity" -import { AnalyticsModule } from './analytics/analytics.module'; -import { RewardShopModule } from './reward-shop/reward-shop.module'; -import { ApiKeyModule } from './api-key/api-key.module'; -import { NestFactory } from '@nestjs/core'; import { Logger, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { AppModule } from './app.module'; @@ -49,7 +23,9 @@ async function bootstrap(): Promise { // /docs/ route (e.g. /docs/swagger-ui-init.js). Nest evaluates the // exclude list against the registered handler path before the global // prefix is applied. - app.setGlobalPrefix('api', { exclude: [/^docs/] }); + app.setGlobalPrefix('api', { + exclude: ['docs', 'docs-json', 'docs/{*path}'], + }); app.enableCors({ origin: configService.get('appConfig.cors.origin') ?? '*', @@ -79,28 +55,6 @@ async function bootstrap(): Promise { transform: true, forbidNonWhitelisted: false, }), - PuzzleModule, - PuzzleSubmissionModule, - ContentModule, - UserReportCardModule, - PuzzleDependencyModule, - TimeTrialModule, - InAppNotificationsModule, - PuzzleTranslationModule, - NFTClaimModule, - AnalyticsModule, - RewardShopModule, - ApiKeyModule, - UserReactionModule, - MultiplayerQueueModule, - // Redis-backed caching + single-flight for the read-heavy endpoints - // (`/streaks/leaderboard`, `/analytics/puzzles/most-solved`) (#107). - CacheModule, - ], - controllers: [AppController], - providers: [AppService], -}) -export class AppModule {} ); const apiVersion = configService.get('appConfig.apiVersion') ?? '1.0'; diff --git a/backend/src/puzzle-solution-checker/dto/check-solution.dto.ts b/backend/src/puzzle-solution-checker/dto/check-solution.dto.ts new file mode 100644 index 00000000..af503af6 --- /dev/null +++ b/backend/src/puzzle-solution-checker/dto/check-solution.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from 'class-validator'; + +export class CheckSolutionDto { + @ApiProperty({ + description: 'ID of the puzzle being solved', + example: '123e4567-e89b-12d3-a456-426614174000', + }) + @IsUUID() + puzzleId: string; + + @ApiProperty({ + description: 'Answer submitted by the player', + example: 'stellar', + }) + @IsString() + @IsNotEmpty() + @MaxLength(2048) + answer: string; + + @ApiPropertyOptional({ + description: + 'Player ID used to rate limit and correlate attempts. Defaults to the authenticated user when available.', + example: 'player-42', + }) + @IsOptional() + @IsString() + @MaxLength(128) + playerId?: string; +} diff --git a/backend/src/puzzle-solution-checker/entities/puzzle-answer-attempt.entity.ts b/backend/src/puzzle-solution-checker/entities/puzzle-answer-attempt.entity.ts new file mode 100644 index 00000000..64ca5188 --- /dev/null +++ b/backend/src/puzzle-solution-checker/entities/puzzle-answer-attempt.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +export enum PuzzleCheckOutcome { + CORRECT = 'correct', + INCORRECT = 'incorrect', + RATE_LIMITED = 'rate_limited', + NOT_FOUND = 'not_found', +} + +@Entity('puzzle_answer_attempts') +@Index(['playerId', 'puzzleId', 'createdAt']) +@Index(['ipAddress', 'createdAt']) +export class PuzzleAnswerAttempt { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Index() + @Column({ type: 'uuid', nullable: true }) + puzzleId: string | null; + + @Index() + @Column({ type: 'varchar', length: 128, nullable: true }) + playerId: string | null; + + @Column({ type: 'varchar', length: 64 }) + answerHash: string; + + @Column({ type: 'varchar', length: 45, nullable: true }) + ipAddress: string | null; + + @Column({ type: 'varchar', length: 512, nullable: true }) + userAgent: string | null; + + @Column({ type: 'enum', enum: PuzzleCheckOutcome }) + outcome: PuzzleCheckOutcome; + + @Column({ type: 'int', default: 0 }) + durationMs: number; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/backend/src/puzzle-solution-checker/puzzle-solution-checker.controller.ts b/backend/src/puzzle-solution-checker/puzzle-solution-checker.controller.ts new file mode 100644 index 00000000..b78d27cb --- /dev/null +++ b/backend/src/puzzle-solution-checker/puzzle-solution-checker.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { + ApiOperation, + ApiResponse, + ApiTags, +} from '@nestjs/swagger'; +import type { Request } from 'express'; + +import { CheckSolutionDto } from './dto/check-solution.dto'; +import { + CheckSolutionResult, + PuzzleSolutionCheckerService, + SOLUTION_CHECK_RATE_LIMIT_MAX_ATTEMPTS, + SOLUTION_CHECK_RATE_LIMIT_TTL_SECONDS, +} from './puzzle-solution-checker.service'; +import { RateLimit } from '../rate-limiter/rate-limit.decorator'; +import { RateLimitGuard } from '../rate-limiter/rate-limit.guard'; + +@ApiTags('Puzzle Solution') +@Controller('puzzle-solution') +export class PuzzleSolutionCheckerController { + constructor( + private readonly solutionChecker: PuzzleSolutionCheckerService, + ) {} + + @Post('check') + @HttpCode(HttpStatus.OK) + @UseGuards(RateLimitGuard) + @RateLimit({ + ttl: SOLUTION_CHECK_RATE_LIMIT_TTL_SECONDS, + limit: SOLUTION_CHECK_RATE_LIMIT_MAX_ATTEMPTS, + }) + @ApiOperation({ + summary: 'Check an off-chain puzzle answer', + description: + 'Deterministically checks a submitted answer against the server-side puzzle solution before on-chain submission.', + }) + @ApiResponse({ + status: 200, + description: 'Answer checked successfully.', + schema: { + type: 'object', + properties: { + isCorrect: { type: 'boolean' }, + attempts: { type: 'number' }, + feedback: { type: 'string' }, + }, + }, + }) + @ApiResponse({ status: 404, description: 'Puzzle not found.' }) + @ApiResponse({ + status: 429, + description: 'Too many solution checks for this player or IP.', + }) + check( + @Body() dto: CheckSolutionDto, + @Req() request: Request, + ): Promise { + const authenticatedUserId = (request as Request & { + user?: { id?: string }; + }).user?.id; + + return this.solutionChecker.checkSolution({ + puzzleId: dto.puzzleId, + answer: dto.answer, + playerId: dto.playerId ?? authenticatedUserId, + ipAddress: request.ip, + userAgent: request.get('user-agent') ?? undefined, + }); + } +} diff --git a/backend/src/puzzle-solution-checker/puzzle-solution-checker.module.ts b/backend/src/puzzle-solution-checker/puzzle-solution-checker.module.ts new file mode 100644 index 00000000..c3510fc6 --- /dev/null +++ b/backend/src/puzzle-solution-checker/puzzle-solution-checker.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { Puzzle } from '../puzzle/puzzle.entity'; +import { RateLimiterModule } from '../rate-limiter/rate-limiter.module'; +import { PuzzleAnswerAttempt } from './entities/puzzle-answer-attempt.entity'; +import { PuzzleSolutionCheckerController } from './puzzle-solution-checker.controller'; +import { PuzzleSolutionCheckerService } from './puzzle-solution-checker.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([Puzzle, PuzzleAnswerAttempt]), + RateLimiterModule.forRoot(), + ], + controllers: [PuzzleSolutionCheckerController], + providers: [PuzzleSolutionCheckerService], + exports: [PuzzleSolutionCheckerService], +}) +export class PuzzleSolutionCheckerModule {} diff --git a/backend/src/puzzle-solution-checker/puzzle-solution-checker.service.spec.ts b/backend/src/puzzle-solution-checker/puzzle-solution-checker.service.spec.ts new file mode 100644 index 00000000..d4e485f0 --- /dev/null +++ b/backend/src/puzzle-solution-checker/puzzle-solution-checker.service.spec.ts @@ -0,0 +1,177 @@ +import { HttpException, NotFoundException } from '@nestjs/common'; + +import { Puzzle } from '../puzzle/puzzle.entity'; +import { + PuzzleAnswerAttempt, + PuzzleCheckOutcome, +} from './entities/puzzle-answer-attempt.entity'; +import { PuzzleSolutionCheckerService } from './puzzle-solution-checker.service'; + +describe('PuzzleSolutionCheckerService', () => { + let service: PuzzleSolutionCheckerService; + + const puzzleRepository = { + findOne: jest.fn(), + }; + + const attemptRepository = { + create: jest.fn((value) => value), + save: jest.fn((value) => Promise.resolve(value)), + count: jest.fn(), + }; + + const rateLimiterService = { + isRateLimited: jest.fn(), + }; + + const activePuzzle = { + id: '123e4567-e89b-12d3-a456-426614174000', + solution: 'Stellar', + isActive: true, + } as Puzzle; + + beforeEach(() => { + jest.clearAllMocks(); + attemptRepository.count.mockResolvedValue(1); + rateLimiterService.isRateLimited.mockReturnValue(false); + + service = new PuzzleSolutionCheckerService( + puzzleRepository as any, + attemptRepository as any, + rateLimiterService as any, + ); + }); + + it('accepts an answer after deterministic normalisation', async () => { + puzzleRepository.findOne.mockResolvedValue(activePuzzle); + + const result = await service.checkSolution({ + puzzleId: activePuzzle.id, + answer: ' STELLAR ', + }); + + expect(result).toEqual({ + isCorrect: true, + attempts: 1, + feedback: 'Correct answer!', + }); + expect(puzzleRepository.findOne).toHaveBeenCalledWith({ + where: { id: activePuzzle.id, isActive: true }, + }); + }); + + it('rejects an incorrect answer', async () => { + puzzleRepository.findOne.mockResolvedValue(activePuzzle); + + const result = await service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'soroban', + }); + + expect(result.isCorrect).toBe(false); + expect(result.feedback).toBe('Incorrect answer. Try again.'); + }); + + it('persists a hash of the attempt instead of the raw answer', async () => { + puzzleRepository.findOne.mockResolvedValue(activePuzzle); + + await service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'stellar', + playerId: 'player-1', + ipAddress: '127.0.0.1', + userAgent: 'jest', + }); + + const savedAttempt = attemptRepository.save.mock + .calls[0][0] as PuzzleAnswerAttempt; + + expect(savedAttempt.answerHash).toMatch(/^[a-f0-9]{64}$/); + expect(savedAttempt.answerHash).not.toContain('stellar'); + expect(savedAttempt.outcome).toBe(PuzzleCheckOutcome.CORRECT); + expect(savedAttempt.playerId).toBe('player-1'); + expect(savedAttempt.ipAddress).toBe('127.0.0.1'); + expect(savedAttempt.durationMs).toBeGreaterThanOrEqual(0); + }); + + it('counts attempts for the player and puzzle', async () => { + puzzleRepository.findOne.mockResolvedValue(activePuzzle); + attemptRepository.count.mockResolvedValue(3); + + const result = await service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'wrong', + playerId: 'player-1', + }); + + expect(attemptRepository.count).toHaveBeenCalledWith({ + where: { playerId: 'player-1', puzzleId: activePuzzle.id }, + }); + expect(result.attempts).toBe(3); + }); + + it('returns 429, logs the attempt, and skips the puzzle lookup when rate limited', async () => { + rateLimiterService.isRateLimited.mockReturnValue(true); + + await expect( + service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'stellar', + playerId: 'player-1', + }), + ).rejects.toBeInstanceOf(HttpException); + + expect(puzzleRepository.findOne).not.toHaveBeenCalled(); + expect(attemptRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: PuzzleCheckOutcome.RATE_LIMITED, + }), + ); + }); + + it('returns 404 and logs the attempt when the puzzle does not exist', async () => { + puzzleRepository.findOne.mockResolvedValue(null); + + await expect( + service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'stellar', + }), + ).rejects.toBeInstanceOf(NotFoundException); + + expect(attemptRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + outcome: PuzzleCheckOutcome.NOT_FOUND, + }), + ); + }); + + it('uses a per-player and per-puzzle rate limit key', async () => { + puzzleRepository.findOne.mockResolvedValue(activePuzzle); + + await service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'stellar', + playerId: 'player-1', + }); + + expect(rateLimiterService.isRateLimited).toHaveBeenCalledWith( + `puzzle-solution:player-1:${activePuzzle.id}`, + 60, + 10, + ); + }); + + it('does not fail the check if attempt logging fails', async () => { + puzzleRepository.findOne.mockResolvedValue(activePuzzle); + attemptRepository.save.mockRejectedValueOnce(new Error('database down')); + + const result = await service.checkSolution({ + puzzleId: activePuzzle.id, + answer: 'stellar', + }); + + expect(result.isCorrect).toBe(true); + expect(result.attempts).toBe(0); + }); +}); diff --git a/backend/src/puzzle-solution-checker/puzzle-solution-checker.service.ts b/backend/src/puzzle-solution-checker/puzzle-solution-checker.service.ts new file mode 100644 index 00000000..258562b3 --- /dev/null +++ b/backend/src/puzzle-solution-checker/puzzle-solution-checker.service.ts @@ -0,0 +1,159 @@ +import { + HttpException, + HttpStatus, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { createHash, timingSafeEqual } from 'crypto'; +import { Repository } from 'typeorm'; + +import { Puzzle } from '../puzzle/puzzle.entity'; +import { + PuzzleAnswerAttempt, + PuzzleCheckOutcome, +} from './entities/puzzle-answer-attempt.entity'; +import { RateLimiterService } from '../rate-limiter/rate-limiter.service'; + +export const SOLUTION_CHECK_RATE_LIMIT_TTL_SECONDS = 60; +export const SOLUTION_CHECK_RATE_LIMIT_MAX_ATTEMPTS = 10; + +export interface CheckSolutionInput { + puzzleId: string; + answer: string; + playerId?: string; + ipAddress?: string; + userAgent?: string; +} + +export interface CheckSolutionResult { + isCorrect: boolean; + attempts: number; + feedback: string; +} + +@Injectable() +export class PuzzleSolutionCheckerService { + private readonly logger = new Logger(PuzzleSolutionCheckerService.name); + + constructor( + @InjectRepository(Puzzle) + private readonly puzzleRepository: Repository, + @InjectRepository(PuzzleAnswerAttempt) + private readonly attemptRepository: Repository, + private readonly rateLimiterService: RateLimiterService, + ) {} + + /** + * Checks an off-chain answer against the solution stored on the server. + * + * The submitted answer is never returned to the caller and only a hash of it + * is persisted. This keeps the checker deterministic while still producing + * an audit trail that can be used to detect abusive players or IPs. + */ + async checkSolution( + input: CheckSolutionInput, + ): Promise { + const startedAt = Date.now(); + const identity = input.playerId || input.ipAddress || 'anonymous'; + const rateLimitKey = `puzzle-solution:${identity}:${input.puzzleId}`; + + if ( + this.rateLimiterService.isRateLimited( + rateLimitKey, + SOLUTION_CHECK_RATE_LIMIT_TTL_SECONDS, + SOLUTION_CHECK_RATE_LIMIT_MAX_ATTEMPTS, + ) + ) { + await this.logAttempt(input, PuzzleCheckOutcome.RATE_LIMITED, startedAt); + throw new HttpException( + 'Too many solution checks. Please try again later.', + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + const puzzle = await this.puzzleRepository.findOne({ + where: { id: input.puzzleId, isActive: true }, + }); + + if (!puzzle) { + await this.logAttempt(input, PuzzleCheckOutcome.NOT_FOUND, startedAt); + throw new NotFoundException('Puzzle not found'); + } + + const isCorrect = this.answersMatch(input.answer, puzzle.solution); + const outcome = isCorrect + ? PuzzleCheckOutcome.CORRECT + : PuzzleCheckOutcome.INCORRECT; + const attempts = await this.logAttempt(input, outcome, startedAt); + + return { + isCorrect, + attempts, + feedback: isCorrect + ? 'Correct answer!' + : 'Incorrect answer. Try again.', + }; + } + + private answersMatch(submitted: string, expected: string): boolean { + const submittedDigest = this.digest(this.normalizeAnswer(submitted)); + const expectedDigest = this.digest(this.normalizeAnswer(expected)); + + return timingSafeEqual(submittedDigest, expectedDigest); + } + + private normalizeAnswer(answer: string): string { + return answer.normalize('NFKC').trim().toLowerCase(); + } + + private digest(value: string): Buffer { + return createHash('sha256').update(value, 'utf8').digest(); + } + + private async logAttempt( + input: CheckSolutionInput, + outcome: PuzzleCheckOutcome, + startedAt: number, + ): Promise { + try { + const attempt = this.attemptRepository.create({ + puzzleId: input.puzzleId, + playerId: input.playerId ?? null, + answerHash: this.digest(this.normalizeAnswer(input.answer)).toString( + 'hex', + ), + ipAddress: input.ipAddress ?? null, + userAgent: input.userAgent?.slice(0, 512) ?? null, + outcome, + durationMs: Date.now() - startedAt, + }); + + await this.attemptRepository.save(attempt); + + return await this.countAttempts(input); + } catch (error) { + this.logger.error( + `Failed to persist puzzle solution attempt: ${error.message}`, + ); + return 0; + } + } + + private async countAttempts(input: CheckSolutionInput): Promise { + if (input.playerId) { + return this.attemptRepository.count({ + where: { playerId: input.playerId, puzzleId: input.puzzleId }, + }); + } + + if (input.ipAddress) { + return this.attemptRepository.count({ + where: { ipAddress: input.ipAddress, puzzleId: input.puzzleId }, + }); + } + + return 1; + } +}