From 7c5ae5381b573bfce71f461381c52c7c02889ace Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 6 Mar 2026 09:17:36 +0100 Subject: [PATCH 1/3] fix: prevent unauthorized match winner setting and add state validation Fix inverted permission checks in setMatchWinner and forfeitMatch that allowed non-organizers to set winners/forfeit while blocking organizers. Add match state validation to reject winner setting on matches that haven't started or already ended, and reject forfeiting ended matches. Closes #312 --- src/matches/matches.controller.ts | 56 +++++++++++++++++++++++++++++-- 1 file changed, 54 insertions(+), 2 deletions(-) diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index cdc4d2db..fbb26fb3 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -606,10 +606,37 @@ export class MatchesController { }) { const { match_id, user, winning_lineup_id } = data; - if (await this.matchAssistant.isOrganizer(match_id, user)) { + if (!(await this.matchAssistant.isOrganizer(match_id, user))) { throw Error("you are not a match organizer"); } + const { matches_by_pk: matchToSetWinner } = await this.hasura.query({ + matches_by_pk: { + __args: { + id: match_id, + }, + status: true, + }, + }); + + if (!matchToSetWinner) { + throw Error("match not found"); + } + + const terminalOrPreStartStatuses: string[] = [ + "Finished", + "Canceled", + "Forfeit", + "Tie", + "Surrendered", + "Scheduled", + "PickingPlayers", + ]; + + if (terminalOrPreStartStatuses.includes(matchToSetWinner.status)) { + throw Error("cannot set winner for a match in this state"); + } + await this.hasura.mutation({ update_matches_by_pk: { __args: { @@ -641,10 +668,35 @@ export class MatchesController { }) { const { match_id, user, winning_lineup_id } = data; - if (await this.matchAssistant.isOrganizer(match_id, user)) { + if (!(await this.matchAssistant.isOrganizer(match_id, user))) { throw Error("you are not a match organizer"); } + const { matches_by_pk: matchToForfeit } = await this.hasura.query({ + matches_by_pk: { + __args: { + id: match_id, + }, + status: true, + }, + }); + + if (!matchToForfeit) { + throw Error("match not found"); + } + + const terminalStatuses: string[] = [ + "Finished", + "Canceled", + "Forfeit", + "Tie", + "Surrendered", + ]; + + if (terminalStatuses.includes(matchToForfeit.status)) { + throw Error("cannot forfeit a match that has already ended"); + } + const { update_matches_by_pk: match } = await this.hasura.mutation({ update_matches_by_pk: { __args: { From 6ec1a4d9090e4becd1be0ec6aca3bb22aa67f0ba Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 6 Mar 2026 09:25:17 +0100 Subject: [PATCH 2/3] refactor: extract shared terminal match status constants Replace duplicated terminal status strings across 4 locations in matches.controller.ts with shared static class constants. --- src/matches/matches.controller.ts | 50 +++++++++++-------------------- 1 file changed, 18 insertions(+), 32 deletions(-) diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index fbb26fb3..f8615613 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -37,6 +37,20 @@ import { MatchRelayService } from "./match-relay/match-relay.service"; export class MatchesController { private readonly appConfig: AppConfig; + private static readonly TERMINAL_STATUSES: string[] = [ + "Finished", + "Canceled", + "Forfeit", + "Tie", + "Surrendered", + ]; + + private static readonly TERMINAL_OR_PRE_START_STATUSES: string[] = [ + ...MatchesController.TERMINAL_STATUSES, + "Scheduled", + "PickingPlayers", + ]; + constructor( private readonly logger: Logger, private readonly hasura: HasuraService, @@ -187,13 +201,7 @@ export class MatchesController { throw Error("unable to find match"); } - if ( - matches_by_pk.status === "Tie" || - matches_by_pk.status === "Canceled" || - matches_by_pk.status === "Forfeit" || - matches_by_pk.status === "Finished" || - matches_by_pk.status === "Surrendered" - ) { + if (MatchesController.TERMINAL_STATUSES.includes(matches_by_pk.status)) { response.status(204).end(); return; } @@ -330,11 +338,7 @@ export class MatchesController { */ if ( data.op === "DELETE" || - status === "Tie" || - status === "Forfeit" || - status === "Canceled" || - status === "Finished" || - status === "Surrendered" + MatchesController.TERMINAL_STATUSES.includes(status) ) { this.matchRelayService.removeBroadcast(matchId); await this.removeDiscordIntegration(matchId); @@ -623,17 +627,7 @@ export class MatchesController { throw Error("match not found"); } - const terminalOrPreStartStatuses: string[] = [ - "Finished", - "Canceled", - "Forfeit", - "Tie", - "Surrendered", - "Scheduled", - "PickingPlayers", - ]; - - if (terminalOrPreStartStatuses.includes(matchToSetWinner.status)) { + if (MatchesController.TERMINAL_OR_PRE_START_STATUSES.includes(matchToSetWinner.status)) { throw Error("cannot set winner for a match in this state"); } @@ -685,15 +679,7 @@ export class MatchesController { throw Error("match not found"); } - const terminalStatuses: string[] = [ - "Finished", - "Canceled", - "Forfeit", - "Tie", - "Surrendered", - ]; - - if (terminalStatuses.includes(matchToForfeit.status)) { + if (MatchesController.TERMINAL_STATUSES.includes(matchToForfeit.status)) { throw Error("cannot forfeit a match that has already ended"); } From bc6fc9eb02ea55536f4c66f5c3b5f0c44f9fd91e Mon Sep 17 00:00:00 2001 From: Flegma Date: Fri, 6 Mar 2026 23:48:51 +0100 Subject: [PATCH 3/3] fix: allow setting match winner in any state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove state validation from setMatchWinner per review feedback — organizers should be able to set the winner regardless of match state. Remove unused TERMINAL_OR_PRE_START_STATUSES constant. --- src/matches/matches.controller.ts | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index f8615613..3b052d6e 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -45,12 +45,6 @@ export class MatchesController { "Surrendered", ]; - private static readonly TERMINAL_OR_PRE_START_STATUSES: string[] = [ - ...MatchesController.TERMINAL_STATUSES, - "Scheduled", - "PickingPlayers", - ]; - constructor( private readonly logger: Logger, private readonly hasura: HasuraService, @@ -614,23 +608,6 @@ export class MatchesController { throw Error("you are not a match organizer"); } - const { matches_by_pk: matchToSetWinner } = await this.hasura.query({ - matches_by_pk: { - __args: { - id: match_id, - }, - status: true, - }, - }); - - if (!matchToSetWinner) { - throw Error("match not found"); - } - - if (MatchesController.TERMINAL_OR_PRE_START_STATUSES.includes(matchToSetWinner.status)) { - throw Error("cannot set winner for a match in this state"); - } - await this.hasura.mutation({ update_matches_by_pk: { __args: {