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.

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

    Fields

    No fields configured for issues without a type.

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions