feat: Sprint 1 — backend fondations TetaRdPG

Auth SuperOAuth (JWT validation + httpOnly cookie), entités users/characters/level_thresholds,
lazy calculation endurance, seed 100 niveaux, config prod-ready (trust proxy, helmet, CORS, rate limit).
Validé : health 200, auth flow, character CRUD, endurance lazy, 401 sans cookie.
This commit is contained in:
2026-03-15 05:51:02 +01:00
commit da3237bf3f
29 changed files with 7249 additions and 0 deletions

37
src/app.module.ts Normal file
View File

@@ -0,0 +1,37 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule } from '@nestjs/throttler';
import { AuthModule } from './auth/auth.module';
import { CharacterModule } from './character/character.module';
import { HealthController } from './common/health.controller';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'postgres',
url: config.get<string>('DATABASE_URL'),
autoLoadEntities: true,
synchronize: config.get('NODE_ENV') !== 'production',
logging: config.get('NODE_ENV') === 'development',
}),
}),
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 20,
},
]),
AuthModule,
CharacterModule,
],
controllers: [HealthController],
})
export class AppModule {}

View File

@@ -0,0 +1,44 @@
import {
Controller,
Post,
Get,
Body,
Res,
UseGuards,
HttpCode,
HttpStatus,
Req,
} from '@nestjs/common';
import { Throttle } from '@nestjs/throttler';
import { Response, Request } from 'express';
import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard';
import { SetSessionDto } from './dto/set-session.dto';
import { User } from '../user/user.entity';
@Controller('auth')
export class AuthController {
constructor(private readonly authService: AuthService) {}
@Post('session')
@HttpCode(HttpStatus.OK)
@Throttle({ default: { ttl: 60_000, limit: 10 } })
async setSession(
@Body() dto: SetSessionDto,
@Res({ passthrough: true }) res: Response,
) {
return this.authService.setSession(dto, res);
}
@Get('me')
@UseGuards(AuthGuard)
async getMe(@Req() req: Request & { user: User }) {
return this.authService.getMe(req.user);
}
@Post('logout')
@HttpCode(HttpStatus.NO_CONTENT)
logout(@Res({ passthrough: true }) res: Response) {
this.authService.logout(res);
}
}

25
src/auth/auth.module.ts Normal file
View File

@@ -0,0 +1,25 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { JwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { User } from '../user/user.entity';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { AuthGuard } from './guards/auth.guard';
@Module({
imports: [
TypeOrmModule.forFeature([User]),
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
secret: config.get<string>('SUPER_OAUTH_JWT_SECRET'),
}),
}),
],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthGuard, TypeOrmModule],
})
export class AuthModule {}

89
src/auth/auth.service.ts Normal file
View File

@@ -0,0 +1,89 @@
import {
Injectable,
UnauthorizedException,
InternalServerErrorException,
} from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Response } from 'express';
import { User } from '../user/user.entity';
import { SetSessionDto } from './dto/set-session.dto';
// Payload émis par SuperOAuth
interface SuperOAuthPayload {
sub: string; // ID provider (Twitch ID, Discord ID…)
provider: string; // 'twitch' | 'discord' | 'google' | 'github'
username: string;
avatar_url?: string;
iat: number;
exp: number;
}
@Injectable()
export class AuthService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
private readonly jwtService: JwtService,
private readonly configService: ConfigService,
) {}
async setSession(dto: SetSessionDto, res: Response): Promise<Omit<User, 'oauthId'>> {
let payload: SuperOAuthPayload;
try {
payload = await this.jwtService.verifyAsync<SuperOAuthPayload>(dto.jwt, {
secret: this.configService.get<string>('SUPER_OAUTH_JWT_SECRET'),
});
} catch {
throw new UnauthorizedException('JWT SuperOAuth invalide ou expiré');
}
if (!payload.sub || !payload.provider || !payload.username) {
throw new UnauthorizedException('Payload JWT incomplet');
}
// Upsert user
let user = await this.userRepository.findOne({
where: { oauthId: payload.sub, provider: payload.provider },
});
if (!user) {
user = this.userRepository.create({
oauthId: payload.sub,
provider: payload.provider,
username: payload.username,
avatarUrl: payload.avatar_url ?? null,
});
} else {
user.username = payload.username;
user.avatarUrl = payload.avatar_url ?? null;
}
await this.userRepository.save(user);
// Cookie httpOnly signé — valeur = UUID interne
const isProduction = this.configService.get('NODE_ENV') === 'production';
res.cookie('session', user.id, {
httpOnly: true,
signed: true,
secure: isProduction,
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 jours
});
const { oauthId: _, ...safeUser } = user;
return safeUser;
}
async getMe(user: User): Promise<Omit<User, 'oauthId'>> {
const { oauthId: _, ...safeUser } = user;
return safeUser;
}
logout(res: Response): void {
res.clearCookie('session');
}
}

View File

@@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SetSessionDto {
@IsString()
@IsNotEmpty()
jwt: string;
}

View File

@@ -0,0 +1,41 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../../user/user.entity';
@Injectable()
export class AuthGuard implements CanActivate {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const userId: string | false = request.signedCookies?.session;
if (!userId) {
throw new UnauthorizedException('Session manquante ou invalide');
}
// Validation UUID basique avant la requête DB
const uuidRegex =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(userId)) {
throw new UnauthorizedException('Session invalide');
}
const user = await this.userRepository.findOne({ where: { id: userId } });
if (!user) {
throw new UnauthorizedException('Utilisateur introuvable');
}
request.user = user;
return true;
}
}

View File

@@ -0,0 +1,40 @@
import {
Controller,
Post,
Get,
Body,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { Request } from 'express';
import { CharacterService } from './character.service';
import { CreateCharacterDto } from './dto/create-character.dto';
import { AuthGuard } from '../auth/guards/auth.guard';
import { User } from '../user/user.entity';
@Controller('characters')
@UseGuards(AuthGuard)
export class CharacterController {
constructor(private readonly characterService: CharacterService) {}
@Post()
@HttpCode(HttpStatus.CREATED)
create(
@Body() dto: CreateCharacterDto,
@Req() req: Request & { user: User },
) {
return this.characterService.create(dto, req.user);
}
@Get('me')
findMe(@Req() req: Request & { user: User }) {
return this.characterService.findByUser(req.user);
}
@Get('me/endurance')
getEndurance(@Req() req: Request & { user: User }) {
return this.characterService.getEndurance(req.user);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from './entities/character.entity';
import { LevelThreshold } from './entities/level-threshold.entity';
import { CharacterController } from './character.controller';
import { CharacterService } from './character.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Character, LevelThreshold]),
AuthModule, // pour AuthGuard + User repository
],
controllers: [CharacterController],
providers: [CharacterService],
})
export class CharacterModule {}

View File

@@ -0,0 +1,97 @@
import {
Injectable,
ConflictException,
NotFoundException,
BadRequestException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from './entities/character.entity';
import { LevelThreshold } from './entities/level-threshold.entity';
import { CreateCharacterDto } from './dto/create-character.dto';
import { User } from '../user/user.entity';
const STAT_POOL = 10; // 5 stats × 1 base + 5 points à distribuer
const ENDURANCE_REGEN_MINUTES = 6; // 1 pt d'endurance toutes les 6 min = 10 pts/heure
@Injectable()
export class CharacterService {
constructor(
@InjectRepository(Character)
private readonly characterRepository: Repository<Character>,
@InjectRepository(LevelThreshold)
private readonly levelThresholdRepository: Repository<LevelThreshold>,
) {}
// Pattern lazy calculation — pas de timer actif
private calculateEndurance(character: Character): number {
const elapsedMinutes =
(Date.now() - character.lastEnduranceTs.getTime()) / 60_000;
const recharge = Math.floor(elapsedMinutes / ENDURANCE_REGEN_MINUTES);
return Math.min(character.enduranceSaved + recharge, character.enduranceMax);
}
async create(dto: CreateCharacterDto, user: User): Promise<Character & { enduranceCurrent: number }> {
const totalStats =
dto.force + dto.agilite + dto.intelligence + dto.chance + dto.vitalite;
if (totalStats !== STAT_POOL) {
throw new BadRequestException(
`La somme des stats doit être égale à ${STAT_POOL} (reçu : ${totalStats})`,
);
}
const existing = await this.characterRepository.findOne({
where: { userId: user.id },
});
if (existing) {
throw new ConflictException('Ce joueur possède déjà un personnage');
}
const character = this.characterRepository.create({
userId: user.id,
name: dto.name,
force: dto.force,
agilite: dto.agilite,
intelligence: dto.intelligence,
chance: dto.chance,
vitalite: dto.vitalite,
enduranceSaved: 100,
lastEnduranceTs: new Date(),
enduranceMax: 100,
});
const saved = await this.characterRepository.save(character);
return { ...saved, enduranceCurrent: this.calculateEndurance(saved) };
}
async findByUser(user: User): Promise<Character & { enduranceCurrent: number }> {
const character = await this.characterRepository.findOne({
where: { userId: user.id },
});
if (!character) {
throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
}
return { ...character, enduranceCurrent: this.calculateEndurance(character) };
}
async getEndurance(
user: User,
): Promise<{ enduranceCurrent: number; enduranceMax: number; rechargeRatePerHour: number }> {
const character = await this.characterRepository.findOne({
where: { userId: user.id },
});
if (!character) {
throw new NotFoundException('Aucun personnage trouvé pour ce joueur');
}
return {
enduranceCurrent: this.calculateEndurance(character),
enduranceMax: character.enduranceMax,
rechargeRatePerHour: 60 / ENDURANCE_REGEN_MINUTES,
};
}
}

View File

@@ -0,0 +1,35 @@
import { IsInt, IsString, Length, Min, Max } from 'class-validator';
// 5 points de stats à répartir — chaque stat démarre à 1
// Contrainte : force + agilite + intelligence + chance + vitalite = 10 (5 base + 5 extra)
// Validé dans le service
export class CreateCharacterDto {
@IsString()
@Length(2, 100)
name: string;
@IsInt()
@Min(1)
@Max(6)
force: number;
@IsInt()
@Min(1)
@Max(6)
agilite: number;
@IsInt()
@Min(1)
@Max(6)
intelligence: number;
@IsInt()
@Min(1)
@Max(6)
chance: number;
@IsInt()
@Min(1)
@Max(6)
vitalite: number;
}

View File

@@ -0,0 +1,75 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
import { User } from '../../user/user.entity';
@Entity('characters')
@Unique(['userId'])
export class Character {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'user_id' })
userId: string;
@ManyToOne(() => User)
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ length: 100 })
name: string;
@Column({ default: 1 })
level: number;
@Column({ default: 0 })
xp: number;
@Column({ default: 0 })
gold: number;
// Stats (cap : 101)
@Column({ default: 1 })
force: number;
@Column({ default: 1 })
agilite: number;
@Column({ default: 1 })
intelligence: number;
@Column({ default: 1 })
chance: number;
@Column({ default: 1 })
vitalite: number;
@Column({ name: 'hp_current', default: 100 })
hpCurrent: number;
@Column({ name: 'hp_max', default: 100 })
hpMax: number;
// Endurance — lazy calculation (pas de timer actif)
@Column({ name: 'endurance_saved', default: 100 })
enduranceSaved: number;
@Column({ name: 'last_endurance_ts', type: 'timestamp', default: () => 'NOW()' })
lastEnduranceTs: Date;
@Column({ name: 'endurance_max', default: 100 })
enduranceMax: number;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -0,0 +1,10 @@
import { Entity, PrimaryColumn, Column } from 'typeorm';
@Entity('level_thresholds')
export class LevelThreshold {
@PrimaryColumn()
level: number;
@Column({ name: 'xp_required' })
xpRequired: number;
}

View File

@@ -0,0 +1,9 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}

View File

@@ -0,0 +1,14 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { User } from '../user/user.entity';
import { Character } from '../character/entities/character.entity';
import { LevelThreshold } from '../character/entities/level-threshold.entity';
// DataSource pour le CLI TypeORM (migrations manuelles)
export const AppDataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
entities: [User, Character, LevelThreshold],
migrations: ['src/database/migrations/*.ts'],
synchronize: false,
});

46
src/database/seed.ts Normal file
View File

@@ -0,0 +1,46 @@
import 'reflect-metadata';
import { DataSource } from 'typeorm';
import { LevelThreshold } from '../character/entities/level-threshold.entity';
const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL ?? 'postgresql://tetardpg:password@localhost:5432/tetardpg',
entities: [LevelThreshold],
synchronize: false,
});
async function seed() {
await dataSource.initialize();
console.log('DB connectée');
const repo = dataSource.getRepository(LevelThreshold);
const existing = await repo.count();
if (existing >= 100) {
console.log('Level thresholds déjà seedés — skip');
await dataSource.destroy();
return;
}
// XP requis = 100 × level^1.5
// Level 1 : 100 XP
// Level 10 : 3162 XP
// Level 100: 1 000 000 XP
const thresholds: LevelThreshold[] = Array.from({ length: 100 }, (_, i) => {
const level = i + 1;
const threshold = new LevelThreshold();
threshold.level = level;
threshold.xpRequired = Math.round(100 * Math.pow(level, 1.5));
return threshold;
});
await repo.save(thresholds);
console.log('✅ 100 level_thresholds seedés');
await dataSource.destroy();
}
seed().catch((err) => {
console.error('Seed échoué :', err);
process.exit(1);
});

49
src/main.ts Normal file
View File

@@ -0,0 +1,49 @@
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { ValidationPipe } from '@nestjs/common';
import * as cookieParser from 'cookie-parser';
import helmet from 'helmet';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
// VPS derrière Apache / reverse proxy — obligatoire pour rate limiter + IP logs corrects
app.set('trust proxy', 1);
// Security headers
app.use(helmet());
// Cookie parser avec signature
const cookieSecret = process.env.COOKIE_SECRET;
if (!cookieSecret) throw new Error('COOKIE_SECRET manquant');
app.use(cookieParser(cookieSecret));
// CORS — multi-origin depuis l'env
const allowedOrigins = (process.env.FRONTEND_URL ?? 'http://localhost:5173')
.split(',')
.map((o) => o.trim());
app.enableCors({
origin: allowedOrigins,
credentials: true,
});
// Validation globale
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
// Prefix global
app.setGlobalPrefix('api');
const port = process.env.PORT ?? 4000;
await app.listen(port);
console.log(`TetaRdPG backend démarré sur le port ${port}`);
}
bootstrap();

33
src/user/user.entity.ts Normal file
View File

@@ -0,0 +1,33 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
Unique,
} from 'typeorm';
@Entity('users')
@Unique(['oauthId', 'provider'])
export class User {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ name: 'oauth_id', length: 255 })
oauthId: string;
@Column({ length: 50 })
provider: string;
@Column({ length: 255 })
username: string;
@Column({ name: 'avatar_url', type: 'varchar', length: 500, nullable: true })
avatarUrl: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

9
src/user/user.module.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './user.entity';
@Module({
imports: [TypeOrmModule.forFeature([User])],
exports: [TypeOrmModule],
})
export class UserModule {}