Skip to content

[Web] MatchLobbyStore active-match selection and scheduling correctness gaps #549

Description

@Flegma

Three related correctness gaps in MatchLobbyStore that combine to suppress or mis-time the active-match lobby experience.

currentMatch picks newest-created match with no status priority, suppressing the active-match alert

Location: stores/MatchLobbyStore.ts:448 (web)

What: currentMatch is computed as myMatches.value.at(0), and the subscribeToMyMatches query orders results purely by created_at desc (lines 343-347) with no status weighting. The sole consumer that nags the player to go to their live game, MatchActiveAlert.vue, reads useMatchLobbyStore().currentMatch (MatchActiveAlert.vue:118-119) and only shows when that single match's status is in [WaitingForCheckIn, Veto, Live] (MatchActiveAlert.vue:104-126). When the most-recently-created match in myMatches is a non-alertable status (e.g. Scheduled, which the cutoff filter at lines 382-391 admits once it is within an hour of kickoff), currentMatch resolves to it and isAlertable is false, so the alert never fires even though an older alertable (Live / Veto / WaitingForCheckIn) match is also present deeper in the list.

Impact: A player is in a Live match M1 (created earlier) and is then scheduled into a second match M2 (Scheduled, kickoff in 30 min, so it passes the 1h cutoff and M2.created_at > M1.created_at). myMatches = [M2(Scheduled), M1(Live)] by created_at desc, so currentMatch = M2. MatchActiveAlert sees status Scheduled, isAlertable is false, and the player gets no ready/return-to-match alert for the ongoing Live match M1.

Suggested fix: Select currentMatch by status priority (prefer Live/Veto/WaitingForCheckIn over Scheduled) rather than raw created_at ordering, or have MatchActiveAlert scan myMatches for the first alertable match instead of relying on index 0.

Verifier evidence

MatchLobbyStore.ts:343-347 order_by: [ { created_at: order_by.desc } ] (no status weighting). MatchLobbyStore.ts:384-390 admits Scheduled within 1h: if (match.status !== e_match_status_enum.Scheduled) return true; return !!match.scheduled_at && new Date(match.scheduled_at).getTime() <= cutoff; with cutoff = Date.now() + 60*60*1000 (line 382). MatchLobbyStore.ts:448-450 currentMatch: computed(() => { return myMatches.value.at(0); }). MatchActiveAlert.vue:118-119 match(): any { return useMatchLobbyStore().currentMatch; }. MatchActiveAlert.vue ALERT_STATUSES = [WaitingForCheckIn, Veto, Live]; isAlertable(): boolean { return !!this.match && ALERT_STATUSES.includes(this.match.status); } and shouldShow(): boolean { if (!this.isAlertable) return false; ... }.


Scheduled-match cutoff filter is evaluated only on subscription push, so a match never enters the lobby at T-1h on its own

Location: stores/MatchLobbyStore.ts:382 (web)

What: In the subscribeToMyMatches next handler, const cutoff = Date.now() + 60 * 60 * 1000 is computed and applied to filter out Scheduled matches whose scheduled_at is more than one hour away. Because this recomputation only happens when the Hasura subscription delivers a new payload, and the payload does not change merely because wall-clock time advances, a match scheduled more than an hour out is filtered out and will not reappear in myMatches when it later falls inside the one-hour window until some unrelated field on the row changes and triggers a fresh push.

Impact: A match is scheduled for 90 minutes from now; the subscription payload arrives once and the match is filtered out. Fifty minutes later it is well within the 1h window and should appear in the lobby nav / MyUpcoming, but no new subscription event has fired, so myMatches still omits it. It only surfaces once its status changes (e.g. to WaitingForCheckIn) or another column mutates.

Suggested fix: Store the unfiltered matches and derive the visible list through a computed that also depends on a periodically-updated clock ref (e.g. a 30-60s interval), so the cutoff is re-evaluated over time rather than only on subscription pushes.

Verifier evidence

stores/MatchLobbyStore.ts:382 const cutoff = Date.now() + 60 * 60 * 1000;
stores/MatchLobbyStore.ts:383-391 filter assigning myMatches.value = (data?.matches ?? []).filter(... new Date(match.scheduled_at).getTime() <= cutoff), only place myMatches is set.
stores/MatchLobbyStore.ts:330-339 subscription where clause constrains by status: { _in: [...Scheduled...] } only (no scheduled_at bound), so server pushes far-future scheduled matches.
No setInterval/clock ref in file (grep: only Date.now at 382). subscribeToMyMatches called once: AuthStore.ts:159 useMatchLobbyStore().subscribeToMyMatches();


myMatches subscription captures the caller's role once and never resubscribes on role change

Location: stores/MatchLobbyStore.ts:321 (web)

What: subscribeToMyMatches builds its GraphQL where-clause using useAuthStore().isRoleAbove(e_player_roles_enum.match_organizer) evaluated synchronously at subscription-construction time (line 321). The subscription is started exactly once behind AuthStore's postAuthSubscriptionsStarted guard (AuthStore.ts:156-165) and is never torn down/rebuilt when the player's role changes. AuthStore's setMe only calls socket.rejoinAll() on a role change (AuthStore.ts:111-119); it does not resubscribe the Hasura subscriptions. So the organizer branch of the filter is frozen at whatever the role was at first login.

Impact: A user logs in as a plain user (isRoleAbove(match_organizer) === false), so the organizer arm of the _or omits the is_in_lineup constraint. An admin later promotes them to match_organizer during the same session; the live me subscription updates the role but the myMatches subscription keeps its original where-clause, so their organizer/lineup match visibility in the lobby nav is wrong until a full page reload.

Suggested fix: Rebuild (unsubscribe + resubscribe) the myMatches subscription when me.role changes, or express the role-dependent branch via a subscription variable rather than baking it into the query at construction time.

Verifier evidence

MatchLobbyStore.ts:321-329 conditional is_in_lineup based on isRoleAbove; caller AuthStore.ts:159 guarded by postAuthSubscriptionsStarted set once at 157; setMe only calls socket.rejoinAll (AuthStore.ts:116-118); rejoinAll re-joins websocket rooms only (Socket.ts:199-203).


Found by the 2026-07 multi-agent code audit; adversarially verified (CONFIRMED, P2/P3). One of a batch of findings from that pass.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2-mediumCode quality & robustnessaudit-2026-07Findings from the 2026-07 code audit / review passservice:web5stackgg/web servicestate-managementState management issue

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions