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
27 changes: 25 additions & 2 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,41 @@
import { Module } from "@nestjs/common";
import { APP_GUARD } from "@nestjs/core";
import { LoggerModule } from "nestjs-pino";
import { ServiceTokenGuard } from "./common/service-token.guard";
import { HealthController } from "./health.controller";
import { PrismaService } from "./prisma/prisma.service";
import { PrismaModule } from "./prisma/prisma.module";
import { TournamentsModule } from "./tournaments/tournaments.module";
import { UsersModule } from "./users/users.module";

@Module({
imports: [
LoggerModule.forRoot({
pinoHttp: {
transport:
process.env.NODE_ENV !== "production" ? { target: "pino-pretty" } : undefined,
// Never log the BFF shared secret, the forwarded actor headers, or cookies.
redact: {
paths: [
'req.headers["x-bff-service-token"]',
'req.headers["x-user-id"]',
'req.headers["x-anon-token"]',
"req.headers.authorization",
"req.headers.cookie",
],
censor: "[redacted]",
},
},
}),
PrismaModule,
UsersModule,
TournamentsModule,
],
controllers: [HealthController],
providers: [PrismaService],
providers: [
{
provide: APP_GUARD,
useClass: ServiceTokenGuard,
},
],
})
export class AppModule {}
171 changes: 171 additions & 0 deletions apps/api/src/bracket/bracket-generator.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { Injectable, NotImplementedException } from "@nestjs/common";
import { StageSetup } from "@tournamentify/shared";
import { GeneratedGroup, GeneratedMatch, GeneratedRound, GeneratedSlot, GeneratedStage } from "./types";

/**
* Pure, deterministic bracket generator. No DB, no randomness, no external
* libraries — given the same inputs it always produces the same structure.
* Output uses 0-based participantIndex (seed s -> participantIndex s-1).
*/
@Injectable()
export class BracketGenerator {
generateStage(stage: StageSetup, participantCount: number): GeneratedStage {
switch (stage.type) {
case "single_elimination":
return this.generateSingleElimination(stage, participantCount);
case "round_robin":
return this.generateRoundRobin(stage, participantCount);
case "double_elimination":
case "swiss":
throw new NotImplementedException(
"Format wird ab M2/M3 unterstuetzt: " + stage.type,
);
default: {
const _exhaustive: never = stage.type;
throw new NotImplementedException(
"Format wird ab M2/M3 unterstuetzt: " + String(_exhaustive),
);
}
}
}

/**
* Standard single-elimination seeding. The bracket size is the smallest power
* of two >= max(count, 2); short fields are padded with byes. Slot order is
* built by the classic mirror expansion so that top seeds meet only late:
* starting from [1, 2], each pass doubles the list, appending for every seed
* its complement (sum - seed) where sum = currentLength*2 + 1.
*/
private generateSingleElimination(stage: StageSetup, participantCount: number): GeneratedStage {
const count = Math.max(participantCount, 2);
let size = 2;
while (size < count) {
size *= 2;
}

// Build the 1-based seed order for the bracket slots.
let slots: number[] = [1, 2];
while (slots.length < size) {
const sum = slots.length * 2 + 1;
const next: number[] = [];
for (const s of slots) {
next.push(s);
next.push(sum - s);
}
slots = next;
}

const rounds = Math.log2(size);
const generatedRounds: GeneratedRound[] = [];

// Round 1: seat the seeded participants (or byes) into mirror-ordered slots.
const firstRoundMatches: GeneratedMatch[] = [];
const firstRoundMatchCount = size / 2;
for (let i = 0; i < firstRoundMatchCount; i++) {
const seedA = slots[2 * i];
const seedB = slots[2 * i + 1];
firstRoundMatches.push({
number: i + 1,
opponent1: this.seedSlot(seedA, participantCount),
opponent2: this.seedSlot(seedB, participantCount),
});
}
generatedRounds.push({
number: 1,
name: this.roundName(1, rounds),
matches: firstRoundMatches,
});

// Subsequent rounds: each match feeds from the two winners below it.
for (let r = 2; r <= rounds; r++) {
const matchCount = size / Math.pow(2, r);
const matches: GeneratedMatch[] = [];
for (let m = 1; m <= matchCount; m++) {
matches.push({
number: m,
opponent1: this.winnerSource(r - 1, 2 * m - 1),
opponent2: this.winnerSource(r - 1, 2 * m),
});
}
generatedRounds.push({
number: r,
name: this.roundName(r, rounds),
matches,
});
}

const group: GeneratedGroup = { number: 1, rounds: generatedRounds };
return { type: stage.type, name: stage.name, groups: [group] };
}

/**
* Round-robin via the circle method. With an odd field a virtual bye player
* (-1) is added to make the count even; pairings touching -1 are dropped so a
* player simply sits out that day. The first array slot stays fixed while the
* rest rotate, yielding n-1 distinct rounds (Spieltage).
*/
private generateRoundRobin(stage: StageSetup, participantCount: number): GeneratedStage {
const players: number[] = [];
for (let i = 0; i < participantCount; i++) {
players.push(i);
}
if (players.length % 2 === 1) {
players.push(-1); // bye marker
}

const n = players.length;
const rounds = Math.max(n - 1, 0);
const half = n / 2;
const generatedRounds: GeneratedRound[] = [];

const arr = players.slice();
for (let r = 0; r < rounds; r++) {
const matches: GeneratedMatch[] = [];
let matchNumber = 1;
for (let i = 0; i < half; i++) {
const a = arr[i];
const b = arr[n - 1 - i];
if (a !== -1 && b !== -1) {
matches.push({
number: matchNumber++,
opponent1: { kind: "participant", participantIndex: a },
opponent2: { kind: "participant", participantIndex: b },
});
}
}
generatedRounds.push({
number: r + 1,
name: "Spieltag " + (r + 1),
matches,
});

// Rotate everything except the fixed first slot.
arr.splice(0, arr.length, arr[0], arr[n - 1], ...arr.slice(1, n - 1));
}

const group: GeneratedGroup = { number: 1, rounds: generatedRounds };
return { type: stage.type, name: stage.name, groups: [group] };
}

/** A real entrant becomes a participant slot; an absent seed becomes a bye. */
private seedSlot(seed: number, participantCount: number): GeneratedSlot {
if (seed <= participantCount) {
return { kind: "participant", participantIndex: seed - 1 };
}
return { kind: "bye" };
}

private winnerSource(round: number, match: number): GeneratedSlot {
return { kind: "source", source: { type: "winner_of", round, match } };
}

private roundName(round: number, totalRounds: number): string {
if (round === totalRounds) {
return "Finale";
}
if (round === totalRounds - 1) {
return "Halbfinale";
}
return "Runde " + round;
}
}
42 changes: 42 additions & 0 deletions apps/api/src/bracket/stage-type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { StageType as PrismaStageType } from "@prisma/client";
import { StageType } from "@tournamentify/shared";

/**
* Bridges the two enum dialects: the shared/domain layer uses lower_snake
* (zod enum), while Prisma generates UPPER_SNAKE constants. Both switches are
* exhaustive so adding a new format breaks the build until it is mapped here.
*/

export function toPrismaStageType(s: StageType): PrismaStageType {
switch (s) {
case "single_elimination":
return PrismaStageType.SINGLE_ELIMINATION;
case "double_elimination":
return PrismaStageType.DOUBLE_ELIMINATION;
case "round_robin":
return PrismaStageType.ROUND_ROBIN;
case "swiss":
return PrismaStageType.SWISS;
default: {
const _exhaustive: never = s;
return _exhaustive;
}
}
}

export function toDomainStageType(p: PrismaStageType): StageType {
switch (p) {
case PrismaStageType.SINGLE_ELIMINATION:
return "single_elimination";
case PrismaStageType.DOUBLE_ELIMINATION:
return "double_elimination";
case PrismaStageType.ROUND_ROBIN:
return "round_robin";
case PrismaStageType.SWISS:
return "swiss";
default: {
const _exhaustive: never = p;
return _exhaustive;
}
}
}
41 changes: 41 additions & 0 deletions apps/api/src/bracket/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { StageType } from "@tournamentify/shared";

/**
* Pure, DB-free description of a generated bracket. The persistence slice maps
* these shapes onto Prisma Stage/Group/Round/Match rows; the renderer reads the
* same shapes. `participantIndex` is the 0-based index into the input
* participants array (seed s maps to participantIndex s-1).
*/

export interface GeneratedSlot {
kind: "participant" | "bye" | "source" | "empty";
participantIndex?: number;
source?: {
type: "winner_of" | "loser_of";
round: number;
match: number;
};
}

export interface GeneratedMatch {
number: number;
opponent1: GeneratedSlot;
opponent2: GeneratedSlot;
}

export interface GeneratedRound {
number: number;
name: string;
matches: GeneratedMatch[];
}

export interface GeneratedGroup {
number: number;
rounds: GeneratedRound[];
}

export interface GeneratedStage {
type: StageType;
name: string;
groups: GeneratedGroup[];
}
21 changes: 21 additions & 0 deletions apps/api/src/common/current-actor.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { createParamDecorator, ExecutionContext } from "@nestjs/common";
import { Request } from "express";

export interface Actor {
userId?: string;
anonToken?: string;
}

function readHeader(request: Request, name: string): string | undefined {
const value = request.headers[name];
const header = Array.isArray(value) ? value[0] : value;
return typeof header === "string" && header.length > 0 ? header : undefined;
}

export const CurrentActor = createParamDecorator((_data: unknown, ctx: ExecutionContext): Actor => {
const request = ctx.switchToHttp().getRequest<Request>();
return {
userId: readHeader(request, "x-user-id"),
anonToken: readHeader(request, "x-anon-token"),
};
});
6 changes: 6 additions & 0 deletions apps/api/src/common/public.decorator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { SetMetadata } from "@nestjs/common";

export const IS_PUBLIC_KEY = "isPublic";

/** Exempt a route from the global ServiceTokenGuard (e.g. health checks). */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
54 changes: 54 additions & 0 deletions apps/api/src/common/service-token.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { timingSafeEqual } from "crypto";
import { Request } from "express";
import { IS_PUBLIC_KEY } from "./public.decorator";

/**
* Nest is private behind the Next BFF. Every request must carry the shared
* x-bff-service-token. Routes marked @Public() (e.g. health) are exempt.
*/
@Injectable()
export class ServiceTokenGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}

canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}

const expected = process.env.BFF_SERVICE_TOKEN;
if (!expected) {
// Fail closed. main.ts validates this at boot, so we should never get here.
throw new UnauthorizedException();
}

const request = context.switchToHttp().getRequest<Request>();
const provided = request.headers["x-bff-service-token"];
const token = Array.isArray(provided) ? provided[0] : provided;

if (typeof token !== "string" || !this.safeEqual(token, expected)) {
throw new UnauthorizedException();
}

return true;
}

private safeEqual(a: string, b: string): boolean {
const bufferA = Buffer.from(a);
const bufferB = Buffer.from(b);
if (bufferA.length !== bufferB.length) {
return false;
}
return timingSafeEqual(bufferA, bufferB);
}
}
Loading