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
4 changes: 2 additions & 2 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -145,13 +145,13 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: wasm32-unknown-unknown
targets: wasm32v1-none
components: rustfmt

# See onchain/Cargo.lock for pinned dependency resolutions.
- name: Build contracts (release wasm)
working-directory: onchain
run: cargo build --workspace --target wasm32-unknown-unknown --release --locked
run: cargo build --workspace --target wasm32v1-none --release --locked

- name: Format check
working-directory: onchain
Expand Down
12 changes: 6 additions & 6 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
with:
targets: wasm32-unknown-unknown
targets: wasm32v1-none

- name: Setup Node.js
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
Expand Down Expand Up @@ -69,7 +69,7 @@ jobs:

- name: Build onchain contracts
working-directory: onchain
run: cargo build --workspace --target wasm32-unknown-unknown --release --locked
run: cargo build --workspace --target wasm32v1-none --release --locked

- name: Run onchain tests
working-directory: onchain
Expand All @@ -79,7 +79,7 @@ jobs:
working-directory: onchain
run: |
for f in stellar_hunts stellar_hunts_nft stellar_hunts_receiver; do
test -s "target/wasm32-unknown-unknown/release/${f}.wasm" \
test -s "target/wasm32v1-none/release/${f}.wasm" \
|| { echo "::error::missing or empty artifact: ${f}.wasm"; exit 1; }
done

Expand Down Expand Up @@ -147,7 +147,7 @@ jobs:
prerelease: false
generate_release_notes: false
files: |
onchain/target/wasm32-unknown-unknown/release/stellar_hunts.wasm
onchain/target/wasm32-unknown-unknown/release/stellar_hunts_nft.wasm
onchain/target/wasm32-unknown-unknown/release/stellar_hunts_receiver.wasm
onchain/target/wasm32v1-none/release/stellar_hunts.wasm
onchain/target/wasm32v1-none/release/stellar_hunts_nft.wasm
onchain/target/wasm32v1-none/release/stellar_hunts_receiver.wasm
fail_on_unmatched_files: true
9 changes: 9 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ docs(api): document rewards claim endpoint
- **Formatting**: Run `npm run format` (Prettier) before committing
- **Modules**: Follow NestJS modular architecture — each feature gets its own module with `controller`, `service`, and `entity` files
- **DTOs**: Validate all inputs using `class-validator` decorators
- Every DTO bound with `@Body()` (a request DTO) must decorate its required
fields (`@IsString`, `@IsInt`, `@IsUUID`, …) so the global `ValidationPipe`
(`whitelist: true`, `forbidNonWhitelisted: true`) can enforce types and
reject unknown properties instead of silently stripping them (issue #529).
- Response-only DTOs (shapes returned to clients, never bound as a request
body) must start with the marker comment `// Response-only DTO` and carry
no validation decorators by design.
- `update-*` DTOs should extend their decorated `create-*` base via
`PartialType` so the whitelisted properties are inherited.
- **API docs**: Use Swagger decorators (`@ApiTags`, `@ApiOperation`, `@ApiResponse`) for all endpoints

### Onchain (Soroban / Rust)
Expand Down
7 changes: 3 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,9 @@
"@nestjs/jwt": "^12.0.1",
"@nestjs/mapped-types": "*",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/platform-socket.io": "^12.0.4",
"@nestjs/platform-express": "^11.2.6",
"@nestjs/platform-socket.io": "^11.2.6",
"@nestjs/schedule": "^12.0.1",
"@nestjs/serve-static": "^12.0.0",
"@nestjs/swagger": "^11.4.7",
"@nestjs/terminus": "^11.1.1",
"@nestjs/typeorm": "^12.0.1",
Expand Down Expand Up @@ -129,4 +128,4 @@
}
}
}
}
}
2 changes: 2 additions & 0 deletions backend/src/achievement/dto/player-achievement.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';

export class PlayerAchievementDto {
Expand Down
27 changes: 1 addition & 26 deletions backend/src/analytic/analytic.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export class AnalyticService {
const sql = effectiveLimit !== undefined
? `SELECT puzzle_id, solve_count FROM puzzle_stats_mv
ORDER BY solve_count DESC LIMIT $1 OFFSET $2`
: hasOffset
: offset !== undefined
? `SELECT puzzle_id, solve_count FROM puzzle_stats_mv
ORDER BY solve_count DESC OFFSET $1`
: `SELECT puzzle_id, solve_count FROM puzzle_stats_mv
Expand Down Expand Up @@ -190,31 +190,6 @@ export class AnalyticService {
return result;
}

private aggregateFallback(): Array<{ puzzle_id: string; solve_count: number }> {
const counts = new Map<string, number>();
for (const row of this.fallbackRows) counts.set(row.puzzle_id, (counts.get(row.puzzle_id) ?? 0) + 1);
return [...counts].map(([puzzle_id, solve_count]) => ({ puzzle_id, solve_count }));
}

private aggregateFallbackAverage(puzzleId: string): { solve_count: number; total_solve_time: number } {
const rows = this.fallbackRows.filter((row) => row.puzzle_id === puzzleId);
return { solve_count: rows.length, total_solve_time: rows.reduce((sum, row) => sum + row.solve_time, 0) };
}

private aggregateFallbackForUser(userId: string) {
const grouped = new Map<string, typeof this.fallbackRows>();
for (const row of this.fallbackRows.filter((item) => item.user_id === userId)) {
grouped.set(row.puzzle_id, [...(grouped.get(row.puzzle_id) ?? []), row]);
}
return [...grouped].map(([puzzle_id, rows]) => ({
puzzle_id,
solve_count: rows.length,
attempts: rows.length,
total_solve_time: rows.reduce((sum, row) => sum + row.solve_time, 0),
last_solved: rows.reduce((latest, row) => row.solved_at > latest ? row.solved_at : latest, rows[0].solved_at),
}));
}

/**
* Paginated user history, most recently solved first. Pagination is
* done in SQL (LIMIT/OFFSET) rather than in memory, so it stays cheap
Expand Down
1 change: 0 additions & 1 deletion backend/src/analytic/dto/create-analytic.dto.ts

This file was deleted.

4 changes: 0 additions & 4 deletions backend/src/analytic/dto/update-analytic.dto.ts

This file was deleted.

1 change: 0 additions & 1 deletion backend/src/api-key/dto/create-api-key.dto.ts

This file was deleted.

4 changes: 0 additions & 4 deletions backend/src/api-key/dto/update-api-key.dto.ts

This file was deleted.

1 change: 1 addition & 0 deletions backend/src/auth/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { Auth } from '../decorators/auth-decorator';
import { AuthType } from '../enums/auth-type.enum';
import { AuthResponseDto } from '../dto/auth-response.dto';
import { GenericAuthMessageDto } from '../dto/generic-auth-message.dto';
import { RegisterDto } from '../dto/register.dto';
import { LoginDto } from '../dto/login.dto';
import { RefreshTokenDto } from '../dto/refresh-token.dto';
Expand Down Expand Up @@ -106,7 +107,7 @@
description: 'Unauthorized - invalid or expired token',
})
async getProfile(@Request() req: { user: User }) {
const { password, ...userProfile } = req.user;

Check failure on line 110 in backend/src/auth/controllers/auth.controller.ts

View workflow job for this annotation

GitHub Actions / Backend lint

'password' is assigned a value but never used
return {
message: 'Profile retrieved successfully',
user: userProfile,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/auth/dto/auth-response.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';

// Step 1: Create a nested DTO for the user
Expand Down
2 changes: 2 additions & 0 deletions backend/src/auth/dto/generic-auth-message.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from "@nestjs/swagger"

// Anti-enumeration response body returned by public auth endpoints when the
Expand Down
13 changes: 13 additions & 0 deletions backend/src/auth/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as crypto from 'crypto';
import { User } from '../entities/user.entity';
import { RegisterDto } from '../dto/register.dto';
import { AuthResponseDto } from '../dto/auth-response.dto';
import { GenericAuthMessageDto } from '../dto/generic-auth-message.dto';
import { LoginDto } from '../dto/login.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { UserTokenHistoryService } from '../../user-token-history/services/user-token-history.service';
Expand Down Expand Up @@ -90,6 +91,18 @@ export class AuthService {
}
}


/**
* Anti-enumeration: the neutral, account-existence-neutral success body
* returned for duplicate registrations (see GenericAuthMessageDto).
*/
private genericRegistrationMessage(): GenericAuthMessageDto {
return {
message:
'Registration successful. If an account already exists, please log in.',
};
}

/**
* Registers a new user.
*
Expand Down
13 changes: 13 additions & 0 deletions backend/src/badge/dto/assign-badge.dto.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsInt, IsPositive } from 'class-validator';

// Request body for POST /badges/assign. Both fields are required: without
// decorators the global ValidationPipe (whitelist: true) would strip the
// entire payload and the handler would receive an empty object (issue #529).
export class AssignBadgeDto {
@ApiProperty({ description: 'ID of the user to assign the badge to', example: 1 })
@IsInt()
@IsPositive()
userId: number;

@ApiProperty({ description: 'ID of the badge to assign', example: 1 })
@IsInt()
@IsPositive()
badgeId: number;
}
48 changes: 48 additions & 0 deletions backend/src/config/dto-whitelist.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { AssignBadgeDto } from '../badge/dto/assign-badge.dto';
import { CreateReportCardBodyDto } from '../user-report-card/dto/report-card.dto';

// Replicates the global ValidationPipe options configured in main.ts so the
// assertions below exercise exactly what production requests go through
// (issue #529).
const globalPipe = new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
});

const context = { type: 'body' as const };

describe('Request DTO whitelisting (issue #529)', () => {
it('rejects an empty body when the request DTO has required fields', async () => {
// Before #529 AssignBadgeDto had no decorators: the pipe silently
// stripped the whole body and the handler saw {}.
await expect(
globalPipe.transform({}, { ...context, metatype: AssignBadgeDto }),
).rejects.toBeInstanceOf(BadRequestException);
});

it('rejects a wrongly-typed field on a required request DTO', async () => {
await expect(
globalPipe.transform(
{ userId: 'not-a-number', badgeId: 1 },
{ ...context, metatype: AssignBadgeDto },
),
).rejects.toBeInstanceOf(BadRequestException);
});

it('accepts an empty body for an all-optional request DTO but rejects unknown fields', async () => {
// The report-card create endpoint takes its user id from the route
// param; the body only carries optional overrides.
await expect(
globalPipe.transform({}, { ...context, metatype: CreateReportCardBodyDto }),
).resolves.toBeInstanceOf(CreateReportCardBodyDto);

await expect(
globalPipe.transform(
{ userId: 42 },
{ ...context, metatype: CreateReportCardBodyDto },
),
).rejects.toBeInstanceOf(BadRequestException);
});
});
5 changes: 0 additions & 5 deletions backend/src/hint/create-hint.dto.ts

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';
import { InAppNotificationType } from '../entities/in-app-notification.entity';

Expand Down
14 changes: 11 additions & 3 deletions backend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ import { NestFactory } from '@nestjs/core';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { DOCS_ROUTE_EXCLUSIONS, buildApiPrefix } from './api-prefix';
import { API_DOC_PATH, buildSwaggerConfig } from './swagger';
import { securityHeadersConfig } from './security-headers';
import { setupSwagger } from './swagger';
import { securityHeadersConfig } from './security-headers';

/**
* Hard limit (ms) we allow the graceful shutdown sequence to take before
Expand All @@ -31,7 +30,16 @@ async function bootstrap(): Promise<void> {
credentials: configService.get<boolean>('appConfig.cors.credentials') ?? true,
});
app.use(helmet());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
// forbidNonWhitelisted is the settled posture (issue #529): unknown
// properties must be rejected with 400, not silently stripped. The pipe
// spec in src/config/global-validation-pipe.spec.ts asserts this mode.
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);

// Respect `appConfig.swagger.enabled` (see backend/config/app.config.ts):
// the UI is mounted in development but stays off by default in
Expand Down
2 changes: 2 additions & 0 deletions backend/src/maintenance-mode/dto/maintenance-status.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';

export class MaintenanceStatusDto {
Expand Down
1 change: 0 additions & 1 deletion backend/src/maintenance-mode/maintenance-mode.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Injectable, Logger, type OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import type { Repository } from 'typeorm';
import { MaintenanceConfig } from './entities/maintenance-config.entity';
import type { UpdateMaintenanceConfigDto } from './dto/maintenance-config.dto';
Expand Down
2 changes: 2 additions & 0 deletions backend/src/milestone/dto/milestone-achievement.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import type {
MilestoneCategory,
MilestoneType,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/multiplayer-queue/dto/match-result.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';
import { MatchStatus } from '../entities/match.entity';

Expand Down
2 changes: 2 additions & 0 deletions backend/src/multiplayer-queue/dto/queue-stats.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';

export class QueueStatsDto {
Expand Down
2 changes: 2 additions & 0 deletions backend/src/multiplayer-queue/dto/queue-status.dto.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// Response-only DTO (issue #529): this shape is never bound as a @Body()
// request type, so it carries no class-validator decorators by design.
import { ApiProperty } from '@nestjs/swagger';
import { QueueStatus, SkillLevel } from '../entities/queue.entity';

Expand Down
15 changes: 15 additions & 0 deletions backend/src/multiplayer-queue/multiplayer-queue.gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Server, Socket } from 'socket.io'
import type { MultiplayerQueueService } from './multiplayer-queue.service'
import type { JoinQueueDto } from './dto/join-queue.dto'
import { QueueStatusDto } from './dto/queue-status.dto'
import { MatchResultDto } from './dto/match-result.dto';
import { WsRateLimitGuard } from '../common/guards/ws-rate-limit.guard'
import { WsRateLimit } from '../common/decorators/ws-rate-limit.decorator'

Expand Down Expand Up @@ -41,6 +42,20 @@ export class MultiplayerGateway
this.logger.log(`Client disconnected from multiplayer: ${client.id}`)
}

/**
* Broadcast a newly created match to all connected multiplayer clients so
* the matched players leave the queue immediately without polling. Used by
* MultiplayerQueueService during matchmaking (see issue #529 workspace
* compile fixes; the service has always called this method).
*/
notifyMatchCreated(match: MatchResultDto): void {
if (!this.server) {
this.logger.warn('Socket server not ready; skipping matchCreated broadcast');
return;
}
this.server.emit('matchCreated', match);
}

/**
* Handle a player joining the matchmaking queue.
*/
Expand Down
4 changes: 2 additions & 2 deletions backend/src/multiplayer-queue/multiplayer-queue.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { JoinQueueDto } from './dto/join-queue.dto';
import type { QueueStatusDto } from './dto/queue-status.dto';
import type { MatchResultDto } from './dto/match-result.dto';
import type { QueueStatsDto } from './dto/queue-stats.dto';
import { MultiplayerQueueGateway } from './multiplayer-queue.gateway';
import { MultiplayerGateway } from './multiplayer-queue.gateway';

@Injectable()
export class MultiplayerQueueService {
Expand All @@ -25,7 +25,7 @@ export class MultiplayerQueueService {
@InjectRepository(Match)
private readonly matchRepository: Repository<Match>,
private readonly dataSource: DataSource,
private readonly gateway: MultiplayerQueueGateway,
private readonly gateway: MultiplayerGateway,
) {}

/**
Expand Down
Loading
Loading