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
5 changes: 2 additions & 3 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { ActivityModule } from './activity/activity.module';
import { AnalyticModule } from './analytic/analytic.module';
import { ApiKeyModule } from './api-key/api-key.module';
import { AuditLogModule } from './audit-log/audit-log.module';

Check failure on line 21 in backend/src/app.module.ts

View workflow job for this annotation

GitHub Actions / Backend lint

'AuditLogModule' is defined but never used
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { ContentModule } from './content/content.module';
Expand All @@ -44,7 +44,7 @@
import { PuzzleDependencyModule } from './puzzle-dependency/puzzle-dependency.module';
import { PuzzleDraftModule } from './puzzle-draft/puzzle-draft.module';
import { PuzzleModule } from './puzzle/puzzle.module';
import { PuzzleReviewModule } from './puzzle-review/puzzle-review/puzzle-review.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 { RateLimiterModule } from './rate-limiter/rate-limiter.module';
Expand Down Expand Up @@ -136,8 +136,7 @@
PuzzleDependencyModule,
PuzzleDraftModule,
PuzzleModule,
PuzzleReviewModule,
AuditLogModule,
PuzzleSolutionCheckerModule,
PuzzleSubmissionModule,
PuzzleTranslationModule,
RateLimiterModule,
Expand Down
21 changes: 18 additions & 3 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Logger, ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import helmet from 'helmet';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';

import { AppModule } from './app.module';
import { DOCS_ROUTE_EXCLUSIONS, buildApiPrefix } from './api-prefix';
import { setupSwagger } from './swagger';
Expand All @@ -21,9 +22,23 @@ async function bootstrap(): Promise<void> {
const app = await NestFactory.create(AppModule);
const configService = app.get<ConfigService>(ConfigService);

app.setGlobalPrefix(buildApiPrefix(configService.get<string>('appConfig.apiVersion')), {
exclude: DOCS_ROUTE_EXCLUSIONS,
// The frontend calls endpoints under the `/api` prefix (see
// frontend/store calls to /api/login, /api/register, etc.), so the global
// prefix is set on the whole Nest app. This also resolves issue #105 which
// expects the /api/users/:userId/history URL shape.
//
// Swagger UI is excluded so /docs, its JSON sibling /docs-json, and its
// nested asset routes (e.g. /docs/swagger-ui-init.js) stay at canonical
// paths instead of being double-prefixed to /api. Nest treats string
// entries as exact paths, so we also pass a RegExp to cover /docs/....
// A single anchored regex covers `docs`, `docs-json`, and any nested
// /docs/<asset> 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', 'docs-json', 'docs/{*path}'],
});

app.enableCors({
origin: configService.get<string>('appConfig.cors.origin') ?? '*',
methods: configService.get<string[]>('appConfig.cors.methods') ?? ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
Expand Down
36 changes: 36 additions & 0 deletions backend/src/puzzle-solution-checker/dto/check-solution.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<CheckSolutionResult> {
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,
});
}
}
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading