diff --git a/backend/src/milestone/controllers/milestone.controller.ts b/backend/src/milestone/controllers/milestone.controller.ts index 851864a2..5431a98e 100644 --- a/backend/src/milestone/controllers/milestone.controller.ts +++ b/backend/src/milestone/controllers/milestone.controller.ts @@ -1,77 +1,83 @@ import { + Body, Controller, Get, - Post, - Param, HttpCode, HttpStatus, - Body, + Param, + Post, + UseGuards, } from '@nestjs/common'; +import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; +import { CurrentUser } from '../../auth/decorators/current-user.decorator'; +import { APIKeyGuard } from '../../api-key/api-key.guard'; import type { MilestoneService } from '../services/milestone.service'; +/** + * Identity rules (issue #482): + * - The `/my` routes resolve the user id from the verified JWT principal + * (`req.user.id` via the JwtStrategy validate() hook) — never from a + * placeholder or any client-supplied value. Unauthenticated calls are + * rejected with 401. + * - The `/trigger/*` routes are server-to-server endpoints that mint + * milestone awards for the user named in the body, so they sit behind + * the API key guard (`x-api-key` header) which a browser user cannot + * satisfy. They intentionally do NOT accept JWT auth. + */ @Controller('milestones') export class MilestoneController { constructor(private readonly milestoneService: MilestoneService) {} @Get('my') - async getMyMilestones() { - // In a real app, you'd get userId from JWT token - const req = { user: { id: 'user-id-placeholder' } }; // Mock request object - const userId = req.user.id; - + @UseGuards(JwtAuthGuard) + async getMyMilestones(@CurrentUser('id') userId: string) { return this.milestoneService.getUserMilestones(userId); } @Get('my/stats') - async getMyMilestoneStats() { - // In a real app, you'd get userId from JWT token - const req = { user: { id: 'user-id-placeholder' } }; // Mock request object - const userId = req.user.id; - + @UseGuards(JwtAuthGuard) + async getMyMilestoneStats(@CurrentUser('id') userId: string) { return this.milestoneService.getUserMilestoneStats(userId); } @Get('my/next') - async getMyNextMilestones() { - // In a real app, you'd get userId from JWT token - const req = { user: { id: 'user-id-placeholder' } }; // Mock request object - const userId = req.user.id; - + @UseGuards(JwtAuthGuard) + async getMyNextMilestones(@CurrentUser('id') userId: string) { return this.milestoneService.getNextMilestones(userId); } + /** + * Marks a milestone as viewed for the *calling* user only: the update is + * scoped by `{ id, userId }` in the assignment service, so one user can + * never mutate another user's unread state. + */ @Post('my/:milestoneId/view') @HttpCode(HttpStatus.OK) - async markMilestoneAsViewed(@Param('milestoneId') milestoneId: string) { - // In a real app, you'd get userId from JWT token - const req = { user: { id: 'user-id-placeholder' } }; // Mock request object - const userId = req.user.id; - + @UseGuards(JwtAuthGuard) + async markMilestoneAsViewed( + @CurrentUser('id') userId: string, + @Param('milestoneId') milestoneId: string, + ) { await this.milestoneService.markMilestoneAsViewed(userId, milestoneId); return { success: true }; } - // Endpoint for external systems to trigger milestone checks + // Server-to-server endpoints for external systems to trigger milestone + // checks. Guarded by API key auth, not JWT (see class docblock). @Post('trigger/puzzle-completed') @HttpCode(HttpStatus.OK) + @UseGuards(APIKeyGuard) async triggerPuzzleCompleted( @Body() body: { userId: string; puzzleData?: any }, ) { - return this.milestoneService.onPuzzleCompleted( - body.userId, - body.puzzleData, - ); + return this.milestoneService.onPuzzleCompleted(body.userId, body.puzzleData); } @Post('trigger/streak-updated') @HttpCode(HttpStatus.OK) + @UseGuards(APIKeyGuard) async triggerStreakUpdated( - @Body() - body: { - userId: string; - currentStreak: number; - longestStreak: number; - }, + @Body() body: { userId: string; currentStreak: number; longestStreak: number }, ) { return this.milestoneService.onStreakUpdated( body.userId, diff --git a/backend/src/milestone/milestone.module.ts b/backend/src/milestone/milestone.module.ts index 14aec13a..9d0ea731 100644 --- a/backend/src/milestone/milestone.module.ts +++ b/backend/src/milestone/milestone.module.ts @@ -9,10 +9,14 @@ import { MilestoneAssignmentService } from './services/milestone-assignment.serv import { MilestoneService } from './services/milestone.service'; import { MilestoneController } from './controllers/milestone.controller'; import { UserMilestoneController } from './controllers/user-milestone.controller'; +import { ApiKeyModule } from '../api-key/api-key.module'; @Module({ imports: [ TypeOrmModule.forFeature([MilestoneTemplate, UserMilestone, UserProgress]), + // Provides ApiKeyService for the APIKeyGuard that protects the + // server-to-server `/milestones/trigger/*` routes (issue #482). + ApiKeyModule, ], controllers: [MilestoneController, UserMilestoneController], providers: [