Skip to content

EU launch - #37

Open
tuggernuts1123 wants to merge 8 commits into
openversus:openversusfrom
tuggernuts1123:eu-launch
Open

EU launch#37
tuggernuts1123 wants to merge 8 commits into
openversus:openversusfrom
tuggernuts1123:eu-launch

Conversation

@tuggernuts1123

@tuggernuts1123 tuggernuts1123 commented May 9, 2026

Copy link
Copy Markdown

What's shipping

1. Region-aware matchmaking + multi-host rollback routing (05e93da)

  • New src/services/regions.ts — region taxonomy (12 regions, classified player-side via geo-IP), 2 deployed regions (EAST_US, MANCHESTER), haversine snap from any region to the closest deployed host, queue-expansion proximity table, MATCH_SERVER_OVERRIDES for cross-region pair routing.
  • Geo-IP at /access via geoip-lite — player's region is classified at login and stored on connections:{id}.region.
  • Matchmaker pairs by ELO and region — bidirectional region check before the existing ELO range gate; EAST_US opens to MANCHESTER at 7s, MANCHESTER opens to EAST_US at 8s, all-region open at 15s.
  • handleMatchFound now picks the rollback host IP based on the matched players' regions (getMatchServerRegionrollbackHostIpForRegion); cross-Atlantic matches default to EAST_US per overrides table.
  • Replaced the long-standing region: "MVSI" stub in matchmaking tickets with the real classified region.
  • New env vars: UDP_SERVER_IP_EAST_US, UDP_SERVER_IP_MANCHESTER. Both default to empty and fall back to UDP_SERVER_IPsingle-region operation is byte-identical to today until UDP_SERVER_IP_MANCHESTER is populated.

Reference port from multiversuskoth/mvs-http-server's matchmaking.matching.ts; the original is preserved at reference/upstream/matchmaking.matching.ts for diffing if upstream evolves.


2. Self-serve data deletion request flow (7ec6491)

GDPR Art 17 right-to-erasure path. Players submit a deletion request via /delete-account; admins review and process at /admin/data-deletion-requests.

  • Public form with honeypot + per-IP rate limit (max 3 requests / 24h).
  • Status / cancel UI keyed on the request ID.
  • Discord webhook fires on submit (env-configurable, name + reason + ID; does NOT include requester IP since admin channels may have non-admin observers).
  • Admin review UI with find candidates button — looks up playertesters by exact name + IP, returns up to 10 with identity-strength badges (steam / epic / hw or no identity); click row → highlight + auto-fill matched-ID + auto-save.
  • "Process" button auto-saves the matched ID before processing if it's unsaved.
  • Backend deletion (src/services/dataDeletionService.ts):
    • Deletes: playertesters._id, eloratings.account_id, playerstats.account_id, cosmetics.account_id, friendlists.accountId; pulls accountId from other users' friend lists; sweeps connections:{id}*, player:{id}:*, player_lobby, player_ranked_set, bot_config, etc. from Redis.
    • Anonymizes: match_archives for the last 12 months — decompresses the zstd JSON, replaces accountId / username with deleted_user_<8hex>, recompresses. Co-player records stay intact (Art 17(3) carve-out for statistical purposes / other parties' rights).
  • Admin landing page at /admin listing both tools (banner + data deletion).

3. Privacy policy (6659dff)

  • PRIVACY.md at the repo root — single source of truth, GitHub-rendered for anyone browsing the repo.
  • /privacy and /privacy-policy web routes — same markdown rendered via marked.
  • Footer links from /home and /delete-account.
  • Prominent "Privacy & Your Data" section in README.md so it's visible at the install / setup boundary.
  • Conservative on capability claims — no asserted active cheat detection, fraud prevention, or anti-abuse systems we don't actually run; match archives described accurately as "fun stats + post-hoc rollback / desync investigation."

4. Custom lobby leave→change-mode fix (2b50984, 2bc16e1)

Pre-existing bug, related but standalone: menu → custom lobby → leave → change mode to 2v2 hung on a loading spinner because the leave handler returned a response object describing a new party lobby without ever seeding the legacy Redis keys (lobby:{id}, player_lobby:{aID}, player:{aID}:lobby:{id}). Subsequent set_lobby_mode would read those keys, find the previous party-lobby ID (or nothing), and silently no-op.

  • 2b50984 — seeds all three legacy keys at createPartyLobby construction time.
  • 2bc16e1 — regression follow-up: when the leaving player was in a multi-player party (e.g., entered the custom with a teammate), the seeding logic was overwriting player_lobby and breaking the create_party_lobby SSC's REJOIN flow, orphaning the teammate. The follow-up adds a guard: if the player already has an alive multi-player party lobby, leave the pointer alone and let the existing REJOIN logic handle reconnect.

Summary by Sourcery

Introduce region-aware matchmaking and multi-region rollback routing, add a GDPR-compliant self-serve data deletion flow with admin tooling, publish and serve a formal privacy policy, and fix custom party lobby transitions when leaving custom lobbies.

New Features:

  • Classify players into geographic regions via geo-IP and incorporate region proximity into matchmaking queues and rollback server selection.
  • Expose public web endpoints and UI for players to submit, track, and cancel data deletion requests, with Discord notifications.
  • Add admin web UIs and APIs for reviewing, matching, and processing or rejecting data deletion requests, including anonymization of historical match archives.
  • Serve a privacy policy page rendered from a repository-root PRIVACY.md and link it from the home and deletion pages.

Bug Fixes:

  • Resolve a custom lobby flow where leaving a custom lobby and changing mode failed due to missing or incorrect legacy party lobby Redis state, preserving existing multi-player party lobbies on return.

Enhancements:

  • Extend matchmaking queues to consider player regions alongside ELO, with time-based cross-region expansion and configurable deployed regions and rollback hosts.
  • Persist player regions on connection and propagate them into matchmaking tickets and match server routing decisions.
  • Add an admin landing page consolidating existing banner tools and new data deletion tooling.
  • Clarify privacy and data-handling practices in the README and surface EU hosting details for EU players.

Documentation:

  • Document OpenVersus privacy and data handling in a new PRIVACY.md and surface it via web routes and README links.

Chores:

  • Introduce new environment variables for per-region rollback hosts and data-deletion Discord webhooks, keeping single-region deployments backward-compatible.

tuggernuts1123 and others added 5 commits May 8, 2026 18:13
…yLobby

When a player leaves a custom lobby, leaveLobby() builds a new party-lobby
response object and returns it. The old code ASSUMED the game would call
create_party_lobby SSC next, which would seed the legacy Redis keys
(lobby:{id}, player_lobby:{aID}, player:{aID}:lobby:{id}) — but in practice
the game can fire set_lobby_mode FIRST, before create_party_lobby. When that
happens:

  - set_lobby_mode reads `player_lobby:{aID}` from Redis → returns the OLD,
    now-stale party lobby ID
  - changeLobbyMode operates on that stale lobby ID (or finds nothing)
  - The client hangs on a loading spinner forever because no response is sent
    that matches the lobby it actually thinks it's in

Repro: menu → custom lobby → leave → change mode to 2v2. Hangs.

Fix: seed all three legacy keys inside createPartyLobby() at construction
time (idempotent — the later create_party_lobby SSC call will just upsert
the same keys with identical data). After this change, set_lobby_mode finds
the right lobby regardless of which SSC fires first.

Also includes the SSC rematch online_players gating fix (websocket
removes the player from online_players when the game closes its WS to
enter a match; the rematch handler was running while that flag was
transiently false). Both small fixes verified in local repro.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets up multi-region infrastructure so an EU rollback host can be added
to the existing US-East setup with a single env var change. Until
UDP_SERVER_IP_MANCHESTER is populated in prod .env, behavior is identical
to today (fallback to UDP_SERVER_IP).

What ships:

src/services/regions.ts (new)
  Region toolkit ported from multiversuskoth/mvs-http-server's
  matchmaking.matching.ts (reference copy at reference/upstream/). 12-region
  taxonomy for player classification, 2-region DEPLOYED_REGIONS list
  (EAST_US + MANCHESTER) for actual host selection, haversine-based
  closest-region snap, MATCH_SERVER_OVERRIDES for cross-region pair routing.
  Per-region proximity timings: EAST_US opens to MANCHESTER at 7s,
  MANCHESTER → EAST_US at 8s, all-region open at 15s
  (REGION_INFINITE_AFTER_MS).

  Also adds rollbackHostIpForRegion() — env-backed region→IP map with
  fallback to UDP_SERVER_IP for graceful single-region operation, and
  regionFromIp() — geo-IP lookup via geoip-lite, RFC1918-aware short-
  circuiting, default-to-EAST_US on lookup miss.

src/handlers/access.ts
  Geo-IP classifies player at /access from req.ip and persists region
  on connections:{id} hash.

src/services/matchmakingService.ts
  Replaced "MVSI" stub with real region read from connections:{id}.region.
  Defaults to DEFAULT_REGION (EAST_US) for accounts that haven't relogged
  since this code shipped — they get classified on next login.

src/matchmaking-worker.ts
  New areTicketsRegionCompatible(A, B, now) gate, called before the
  existing ELO range check in both 1v1 and 2v2 pairing paths. Bidirectional
  (both sides must accept each other's home region) so a fresh ticket
  can't be force-paired before its waitMs expires.

src/websocket.ts handleMatchFound
  Replaces the 2-IP coin flip with getMatchServerRegion(...players.region)
  → rollbackHostIpForRegion. Falls back to UDP_SERVER_IP on any error so
  matches don't break if regions Redis read fails.

src/env/env.ts
  - UDP_SERVER_IP_EAST_US (default "")
  - UDP_SERVER_IP_MANCHESTER (default "")
  - DISCORD_DATA_REQUEST_WEBHOOK_URL (default "") for the deletion flow
    landing in commit 3.

Deps: geoip-lite (offline MaxMind data, no API key) and @types/geoip-lite.

Validation: npx tsc --noEmit clean. Single-region operation verified
locally with UDP_SERVER_IP_MANCHESTER unset (falls through to
UDP_SERVER_IP, behavior identical to before).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GDPR Art 17 right-to-erasure path. Players submit a deletion request via
/delete-account; admins review and process in the admin UI.

Public:
  - GET  /delete-account                       Form + status/cancel UI
  - POST /api/data-deletion-request            Submit (rate-limited 3/24h
                                               per IP, honeypot)
  - GET  /api/data-deletion-request/:id        Status (no PII leaked)
  - POST /api/data-deletion-request/:id/cancel Cancel pending request

Admin (gated by existing /admin password middleware):
  - GET  /admin                                Tools landing page
                                               (banner + data-deletion)
  - GET  /admin/data-deletion-requests         Review UI
  - GET  /api/admin/data-deletion-requests
  - GET  .../:id/candidates                    Find playertesters by name+IP
  - POST .../:id/match                         Save matched account ID
  - POST .../:id/process                       Run deletion + anonymization
  - POST .../:id/reject                        Reject with notes

Backend logic (src/services/dataDeletionService.ts):

  DELETE — collections whose documents ARE the player's data:
    playertesters._id, eloratings.account_id, playerstats.account_id,
    cosmetics.account_id, friendlists.accountId. Plus pulls the deleted
    accountId out of every other user's friend list.

    Redis keys: connections:{id}*, player:{id}:*, player_lobby:{id},
    player_ranked_set:{id}, ranked_disconnect:{id}, bot_config:{id},
    pending_join_lobby:{id}, fun_fact_pending:{id},
    ssc_custom_lobby_player:{id}.

  ANONYMIZE — match_archives entries the player participated in:
    decompress (zstd-napi), walk the JSON tree, replace exact-string
    matches of accountId / oldUsername with deleted_user_<8hex>, also
    rename object keys keyed by accountId (e.g. eloBefore[accountId]).
    Recompress in place. Preserves co-player records — other participants
    retain their match history, just with the deleted user anonymized.

    GDPR Art 17(3) carve-outs (statistical purposes / establishment of
    legal claims / other parties' rights) cover keeping these records.

Discord notification:
  - DISCORD_DATA_REQUEST_WEBHOOK_URL env var
  - Posts requestedName, reason, requestId, admin review URL
  - Intentionally OMITS requester IP — admin review UI shows it; channels
    may have non-admin observers
  - Empty env var → silent skip with log warning

Anti-spam:
  - Honeypot field "website" (silent 200 if filled — bots think they
    succeeded but no record created)
  - Per-IP rate limit: max 3 requests in 24h
  - 64-char limit on requestedName, 1000-char on reason

Admin UX:
  - Find candidates: queries playertesters by exact-match name (case-
    insensitive) + same IP, returns up to 10 unique results with
    identity-strength badges (steam/epic/hw or "no identity" red)
  - Click candidate row → highlight + auto-fill matched-ID + auto-save
  - Process button auto-saves matched-ID first if it has a value but
    hasn't been persisted yet
  - 30-second auto-refresh on the review page

Validation: end-to-end tested locally — submit → Discord ping →
review → match → process → confirmed mongo deletes + 35 archives
anonymized for a real test account.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single source of truth in PRIVACY.md at the repo root; rendered to HTML
at /privacy and /privacy-policy via marked. Edit the markdown, restart
the server, both surfaces update together.

Surfaces the policy at the data-collection boundary:
  - GitHub: PRIVACY.md renders natively at the repo root
  - Repo README: prominent "Privacy & Your Data" section before any
    technical setup steps
  - Website: /privacy and /privacy-policy routes
  - In-product: footer links from /home and /delete-account

Covers GDPR Art 13/14 disclosure requirements:
  - Who collects, what, why (with explicit Art 6 lawful basis per
    category)
  - Where data is stored (US-East + EU/Manchester, with cross-border
    handling note)
  - Retention periods
  - User rights (access / correction / erasure / portability /
    objection / complaint)
  - How to exercise rights (delete-account form, Discord ticket,
    GitHub for non-PII technical questions)
  - Children's privacy (platform-level age gates rely on Steam/Epic)
  - Security claims kept narrow + verifiable (JWT-signed tokens, SSH
    key access only, no payment data, firewall on production hosts)
  - Breach notification within 72h (Art 33-34)

Deliberately conservative on capability claims — does not assert active
cheat detection, fraud prevention, or anti-abuse systems we don't
actually run. Match archives are described accurately: "fun stats and
post-hoc rollback / desync bug investigation."

Includes URL/Discord/GitHub placeholders that operators should
substitute with real values before publishing publicly.

Deps: marked (markdown → HTML at startup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Regression in 2b50984: when a player left a custom lobby that they had
entered as part of a multi-player party (e.g., A and B queued the custom
together as friends), the new createPartyLobby seeding logic
unconditionally overwrote player_lobby:{A} with a fresh solo lobby ID.
That broke the create_party_lobby SSC REJOIN path (ssc.ts:424–453)
which requires player_lobby to point at the existing multi-player lobby
to fire — A would end up in a new solo lobby, B would be orphaned in
the original partyAB silently.

Fix: before seeding new solo state, check whether player_lobby already
points at a still-intact multi-player party that includes the leaver.
If so, return only the response object (no Redis writes), preserving
the existing pointer so the subsequent create_party_lobby SSC's REJOIN
path can re-establish the party with both members.

Solo-leave-from-custom path (the original 2b50984 use case — hangs on
change-mode) is unchanged: existing pointer is missing or points at a
single-player or stale lobby → fall through to the seeding logic and
behave as introduced in that commit.

Edge cases handled:
  - existing lobby pointer is stale (lobby:{id} expired) → fall through
    to seeding (treated as no existing party)
  - existing lobby has only the leaver → fall through to seeding
  - JSON parse error on the existing lobby state → fall through with
    a warn log (better to create a fresh lobby than wedge the leave)

Validation: npx tsc --noEmit clean. Logic verified against the REJOIN
path in handleSsc_invoke_create_party_lobby — solo and multi-player
flows now both behave correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented May 9, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements EU-aware functionality across the stack: geo-IP based region tagging and region-aware matchmaking / rollback host routing, introduces a full GDPR-compliant self-serve data deletion workflow (public + admin UIs and backend deletion/anonymization service), adds a repository-root privacy policy surfaced via web routes and UI links, and fixes a custom lobby leave→change-mode regression by seeding legacy lobby keys while preserving multi-player party state.

Sequence diagram for region-aware matchmaking and rollback host routing

sequenceDiagram
  actor Player
  participant HttpServer as HttpServer_access
  participant Redis as Redis
  participant Matchmaking as MatchmakingWorker
  participant WS as WebSocketService
  participant Regions as RegionsService
  participant RollbackUS as Rollback_EAST_US
  participant RollbackEU as Rollback_MANCHESTER

  Player->>HttpServer: POST /access
  HttpServer->>HttpServer: tryGetRealIP
  HttpServer->>Regions: regionFromIp(ip)
  Regions-->>HttpServer: Region
  HttpServer->>Redis: hSet connections:playerId { region }
  HttpServer-->>Player: access token

  Player->>HttpServer: queueMatch (via game API)
  HttpServer->>Redis: read connections:playerId.region
  HttpServer-->>HttpServer: default to DEFAULT_REGION if missing
  HttpServer->>Redis: enqueue RedisMatchTicket with player.region

  loop matchmaking loop
    Matchmaking->>Redis: fetch RedisMatchTicket list
    Matchmaking-->>Matchmaking: sort by created_at
    Matchmaking->>Matchmaking: areTicketsRegionCompatible(ticketA, ticketB, now)
    Matchmaking->>Regions: getAllowedRegions(homeRegion, elapsedMs)
    Regions-->>Matchmaking: Set Region
    Matchmaking-->>Matchmaking: if compatible and areTicketsInRange → match
  end

  Matchmaking-->>WS: MATCH_FOUND_NOTIFICATION (players)

  WS->>Redis: hGetAll connections:playerId for each human
  WS-->>WS: collect Region list (fallback DEFAULT_REGION)
  WS->>Regions: getMatchServerRegion(anchorRegion, otherRegions)
  Regions-->>WS: DeployedRegion
  WS->>Regions: rollbackHostIpForRegion(DeployedRegion)
  Regions-->>WS: serverIp

  alt all players localhost
    WS-->>Player: MATCH_FOUND with IPAddress 127.0.0.1
  else normal case
    WS-->>Player: MATCH_FOUND with IPAddress serverIp
  end

  par match routing
    WS->>RollbackUS: start match (if serverIp = UDP_SERVER_IP_EAST_US)
    WS->>RollbackEU: start match (if serverIp = UDP_SERVER_IP_MANCHESTER)
  end
Loading

Sequence diagram for self-serve data deletion request lifecycle

sequenceDiagram
  actor User
  actor Admin
  participant Web as HttpServer
  participant PublicHandler as DataDeletionPublicHandlers
  participant AdminHandler as DataDeletionAdminHandlers
  participant DDRModel as DataDeletionRequestModel
  participant DeletionSvc as DataDeletionService
  participant PlayerTester as PlayerTesterModel
  participant Elo as EloRatingModel
  participant Stats as PlayerStatsModel
  participant Cosmetics as CosmeticsModel
  participant FriendList as FriendListModel
  participant MatchArchive as MatchArchiveModel
  participant Redis as Redis
  participant Discord as DiscordWebhook

  rect rgb(235,235,255)
    User->>Web: GET /delete-account
    Web-->>User: delete_account.html

    User->>PublicHandler: POST /api/data-deletion-request
    PublicHandler->>PublicHandler: honeypot check
    PublicHandler->>PublicHandler: checkRateLimit(ip)
    PublicHandler->>DDRModel: create(requestedName, reason, requestIp, ua, status=pending)
    DDRModel-->>PublicHandler: DataDeletionRequest
    PublicHandler-->>Discord: POST webhook (name, reason, requestId)
    PublicHandler-->>User: 201 { requestId, status=pending }
  end

  rect rgb(235,255,235)
    Admin->>Web: GET /admin/data-deletion-requests
    Web-->>Admin: admin_data_deletion.html

    Admin->>AdminHandler: GET /api/admin/data-deletion-requests
    AdminHandler->>DDRModel: find(filter by status)
    DDRModel-->>AdminHandler: request list
    AdminHandler-->>Admin: JSON requests

    Admin->>AdminHandler: GET /api/admin/data-deletion-requests/:id/candidates
    AdminHandler->>DDRModel: findById(id)
    DDRModel-->>AdminHandler: DataDeletionRequest
    AdminHandler->>PlayerTester: find by name
    AdminHandler->>PlayerTester: find by ip
    PlayerTester-->>AdminHandler: candidate accounts
    AdminHandler-->>Admin: candidates list

    Admin->>AdminHandler: POST /api/admin/data-deletion-requests/:id/match
    AdminHandler->>DDRModel: findByIdAndUpdate(matchedAccountId, adminNotes)
    DDRModel-->>AdminHandler: updated
    AdminHandler-->>Admin: ok

    Admin->>AdminHandler: POST /api/admin/data-deletion-requests/:id/process
    AdminHandler->>DDRModel: findById(id)
    DDRModel-->>AdminHandler: DataDeletionRequest
    AdminHandler-->>AdminHandler: validate status=pending and matchedAccountId
    AdminHandler->>DeletionSvc: processDeletion(accountId, processedBy)

    DeletionSvc->>PlayerTester: findById(accountId)
    PlayerTester-->>DeletionSvc: player (for oldUsername)

    par delete core records
      DeletionSvc->>PlayerTester: deleteOne(_id=accountId)
      DeletionSvc->>Elo: deleteMany(account_id=accountId)
      DeletionSvc->>Stats: deleteMany(account_id=accountId)
      DeletionSvc->>Cosmetics: deleteMany(account_id=accountId)
      DeletionSvc->>FriendList: deleteOne(accountId)
      DeletionSvc->>FriendList: updateMany($pull friends.friendAccountId=accountId)
    end

    DeletionSvc->>MatchArchive: find(recent)
    loop each archive
      DeletionSvc->>MatchArchive: anonymizeArchive(doc, accountId, oldUsername, anonymizedName)
      MatchArchive-->>DeletionSvc: updated doc
    end

    DeletionSvc->>Redis: del connections, player, lobby keys
    Redis-->>DeletionSvc: counts
    DeletionSvc-->>AdminHandler: DeletionReport

    AdminHandler->>DDRModel: save status=processed, processedBy, processedAt, adminNotes
    DDRModel-->>AdminHandler: saved
    AdminHandler-->>Admin: ok + report
  end

  rect rgb(255,245,235)
    User->>PublicHandler: GET /api/data-deletion-request/:id
    PublicHandler->>DDRModel: findById(id)
    DDRModel-->>PublicHandler: DataDeletionRequest
    PublicHandler-->>User: public status fields

    User->>PublicHandler: POST /api/data-deletion-request/:id/cancel
    PublicHandler->>DDRModel: findById(id)
    DDRModel-->>PublicHandler: DataDeletionRequest
    PublicHandler-->>PublicHandler: ensure status=pending
    PublicHandler->>DDRModel: save status=cancelled
    DDRModel-->>PublicHandler: saved
    PublicHandler-->>User: ok
  end
Loading

ER diagram for data deletion domain and affected entities

erDiagram
  DataDeletionRequest {
    string id PK
    string requestedName
    string reason
    string requestIp
    string requestUserAgent
    string status
    string processedBy
    date processedAt
    string adminNotes
    string matchedAccountId FK
  }

  PlayerTester {
    string id PK
    string name
    string hydraUsername
    string ip
  }

  EloRating {
    string id PK
    string account_id FK
  }

  PlayerStats {
    string id PK
    string account_id FK
  }

  Cosmetics {
    string id PK
    string account_id FK
  }

  FriendList {
    string id PK
    string accountId FK
  }

  MatchArchive {
    string id PK
    buffer compressed_data
  }

  DataDeletionRequest ||--o| PlayerTester : matchedAccountId_to_account
  PlayerTester ||--o{ EloRating : account_has_elo
  PlayerTester ||--o{ PlayerStats : account_has_stats
  PlayerTester ||--o{ Cosmetics : account_has_cosmetics
  PlayerTester ||--o| FriendList : account_has_friendlist
  FriendList }o--o{ PlayerTester : friends_reference_accounts
  PlayerTester ||--o{ MatchArchive : referenced_in_archives
  DataDeletionRequest ||--o{ MatchArchive : anonymizes_archives
Loading

Updated class diagram for regions and data deletion domain

classDiagram
  class RegionsService {
    <<module>>
    REGIONS : Region[]
    DEPLOYED_REGIONS : DeployedRegion[]
    REGION_COORDS : Record~Region,RegionInfo~
    DEFAULT_REGION : Region
    REGION_PROXIMITY : Partial~Record~Region,RegionProximityEntry[]~~
    REGION_INFINITE_AFTER_MS : number
    haversineKm(lat1 number, lon1 number, lat2 number, lon2 number) number
    getClosestRegion(lat number, lon number) Region
    mapToDeployedRegion(region Region) DeployedRegion
    rollbackHostIpForRegion(region DeployedRegion) string
    regionFromIp(ip string) Region
    getMatchServerRegion(anchorRegion Region, otherRegions Region[]) DeployedRegion
    getAllowedRegions(homeRegion Region, elapsedMs number) Set~Region~
  }

  class RegionInfo {
    lat : number
    lon : number
  }

  class RegionProximityEntry {
    region : Region
    waitMs : number
  }

  class DataDeletionRequest {
    requestedName : string
    reason : string
    requestIp : string
    requestUserAgent : string
    status : DataDeletionStatus
    processedBy : string
    processedAt : Date
    adminNotes : string
    matchedAccountId : string
  }

  class DeletionReport {
    accountId : string
    anonymizedDisplayName : string
    mongo : Record~string,number~
    redisKeysDeleted : number
    archivesAnonymized : number
    archiveFailures : number
    errors : string[]
  }

  class DataDeletionService {
    <<service>>
    makeAnonymizedName() string
    deleteRedisKeysByPattern(pattern string) Promise~number~
    anonymizeArchive(doc any, accountId string, oldUsername string, anonymizedName string) Promise~boolean~
    processDeletion(accountId string, processedBy string) Promise~DeletionReport~
  }

  class DataDeletionPublicHandlers {
    <<handlers>>
    handleSubmit(req Request, res Response) Promise~void~
    handleStatus(req Request, res Response) Promise~void~
    handleCancel(req Request, res Response) Promise~void~
  }

  class DataDeletionAdminHandlers {
    <<handlers>>
    handleAdminList(req Request, res Response) Promise~void~
    handleAdminCandidates(req Request, res Response) Promise~void~
    handleAdminMatch(req Request, res Response) Promise~void~
    handleAdminProcess(req Request, res Response) Promise~void~
    handleAdminReject(req Request, res Response) Promise~void~
  }

  class PlayerTesterModel {
    <<model>>
  }

  class EloRatingModel {
    <<model>>
  }

  class PlayerStatsModel {
    <<model>>
  }

  class CosmeticsModel {
    <<model>>
  }

  class FriendListModel {
    <<model>>
  }

  class MatchArchiveModel {
    <<model>>
  }

  RegionsService --> RegionInfo
  RegionsService --> RegionProximityEntry

  DataDeletionService --> DeletionReport
  DataDeletionService --> PlayerTesterModel
  DataDeletionService --> EloRatingModel
  DataDeletionService --> PlayerStatsModel
  DataDeletionService --> CosmeticsModel
  DataDeletionService --> FriendListModel
  DataDeletionService --> MatchArchiveModel

  DataDeletionPublicHandlers --> DataDeletionRequest
  DataDeletionAdminHandlers --> DataDeletionRequest
  DataDeletionAdminHandlers --> DataDeletionService
Loading

File-Level Changes

Change Details Files
Introduce region taxonomy, geo-IP classification, and region-aware matchmaking / rollback host routing with multi-region UDP server configuration.
  • Add regions service defining regions, geo-distance helpers, queue region-proximity rules, deployed-region mapping, and rollback-host IP resolution with new UDP_SERVER_IP_* env vars.
  • Tag connections with player region at /access using geoip-lite, defaulting to EAST_US when classification fails.
  • Include player region in matchmaking tickets and enforce bidirectional region compatibility before ELO checks in 1v1 and 2v2 queues.
  • Select rollback host per match based on players' regions using getMatchServerRegion and route clients to the chosen regional UDP IP, with logging and fallback to legacy behavior.
  • Vendor upstream matchmaking region logic into reference/upstream for future diffing.
src/services/regions.ts
src/env/env.ts
src/handlers/access.ts
src/services/matchmakingService.ts
src/matchmaking-worker.ts
src/websocket.ts
package.json
package-lock.json
reference/upstream/matchmaking.matching.ts
Add GDPR-style self-serve data deletion workflow including public request form, Discord notification, admin review UI, and backend deletion/anonymization service.
  • Define DataDeletionRequest Mongo model with status lifecycle, IP/user-agent capture, and admin metadata.
  • Implement public handlers for submission (with honeypot and per-IP rate limiting), status lookup, and cancellation, plus Discord webhook notification on submit.
  • Implement admin handlers to list requests, find candidate accounts by name/IP, match an account, process deletions, and reject requests with notes.
  • Build dataDeletionService that deletes player-centric collections, prunes other users' friendlists, wipes Redis state by pattern, and anonymizes recent match archives by decompressing zstd JSON and rewriting accountId/username fields.
  • Add static HTML UIs for public delete-account form, admin data-deletion review tool, and admin landing page, wired via new Express routes under /delete-account and /admin/data-deletion-requests, with new DISCORD_DATA_REQUEST_WEBHOOK_URL env support.
src/database/DataDeletionRequest.ts
src/handlers/dataDeletion.ts
src/services/dataDeletionService.ts
src/static/delete_account.html
src/static/admin_data_deletion.html
src/static/admin_landing.html
src/server.ts
src/env/env.ts
Add and surface a repository-root privacy policy and integrate privacy / deletion links into the web UI and README.
  • Add PRIVACY.md as the canonical privacy policy document, describing collection, legal bases, retention, regional hosting, rights and contact paths.
  • Render PRIVACY.md at /privacy and /privacy-policy by reading the markdown at startup, falling back gracefully if the file isn’t co-located with compiled output.
  • Add a privacy policy template and route wiring, plus footer links from the home page and delete-account page to privacy and deletion endpoints.
  • Update README with a "Privacy & Your Data" section linking to PRIVACY.md, delete-account, and Discord, and describing EU cross-border handling and hosting regions.
PRIVACY.md
src/static/privacy.html
src/server.ts
src/static/home.html
README.md
Fix custom lobby leave→change-mode flow by eagerly seeding legacy lobby keys while preserving existing multi-player party lobbies.
  • Extend createPartyLobby to seed legacy Redis lobby state (lobby:{id}, player_lobby:{accountId}, player:{accountId}:lobby:{id}) for the new party lobby so downstream SSC handlers see a valid lobby and set_lobby_mode / changeLobbyMode no longer no-op.
  • Add a guard that detects when the player already belongs to a multi-player party lobby and, in that case, avoids overwriting player_lobby and simply returns a fresh response object, relying on existing REJOIN logic.
  • Add logging and fallthrough behaviour in case of errors reading existing lobby state so the leave flow doesn’t wedge even if the guard fails.
src/modules/customLobby/lobby.service.ts

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@penify-dev

penify-dev Bot commented May 9, 2026

Copy link
Copy Markdown

Failed to generate code suggestions for PR

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 3 issues, and left some high level feedback:

  • In processDeletion, the Redis wipe loop includes the non-account-scoped pattern match_to_set:* despite the comment saying it should be skipped, which means processing a single deletion will delete all match_to_set keys; consider removing this pattern or scoping it to the specific account.
  • The match archive anonymization walks every MatchArchive from the last 12 months and does synchronous zstd decompress/JSON parse/recompress per doc on the request thread; for busy deployments this could block the event loop noticeably, so consider adding an account-id sidecar/index to narrow the query and/or moving the heavy work to a background job or batched worker.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `processDeletion`, the Redis wipe loop includes the non-account-scoped pattern `match_to_set:*` despite the comment saying it should be skipped, which means processing a single deletion will delete *all* `match_to_set` keys; consider removing this pattern or scoping it to the specific account.
- The match archive anonymization walks every `MatchArchive` from the last 12 months and does synchronous zstd decompress/JSON parse/recompress per doc on the request thread; for busy deployments this could block the event loop noticeably, so consider adding an account-id sidecar/index to narrow the query and/or moving the heavy work to a background job or batched worker.

## Individual Comments

### Comment 1
<location path="src/services/dataDeletionService.ts" line_range="232-241" />
<code_context>
+  // ── 5. Wipe Redis keys
+  try {
+    let total = 0;
+    for (const pattern of [
+      `connections:${accountId}`,
+      `connections:${accountId}:*`,
+      `player:${accountId}:*`,
+      `player_lobby:${accountId}`,
+      `player_ranked_set:${accountId}`,
+      `ranked_disconnect:${accountId}`,
+      `bot_config:${accountId}`,
+      `pending_join_lobby:${accountId}`,
+      `fun_fact_pending:${accountId}`,
+      `ssc_custom_lobby_player:${accountId}`,
+      `match_to_set:*`, // these are short-TTL; skip from per-account scan, just leave to expire
+    ]) {
+      if (pattern.endsWith("*")) {
</code_context>
<issue_to_address>
**issue (bug_risk):** `match_to_set:*` pattern wipes keys for all players, not just the deleted account.

Including `match_to_set:*` here will delete keys for every account, which can disrupt active matches for other players. If these keys should be left alone, remove this pattern. If you need targeted cleanup, ensure the key includes `accountId` (and update the pattern accordingly) instead of using a global wildcard.
</issue_to_address>

### Comment 2
<location path="src/services/regions.ts" line_range="290" />
<code_context>
+ *   - Starts with only the home region.
+ *   - Each neighbor unlocks once `elapsedMs` reaches its waitMs threshold.
+ *
+ * For our current 2-region setup, this means an EAST_US ticket starts
+ * matching only with other EAST_US tickets, and after 25s also accepts
+ * MANCHESTER. MANCHESTER tickets accept EAST_US after 15s (asymmetric
</code_context>
<issue_to_address>
**nitpick:** Comments describing region wait timings don't match the actual constants.

The text mentions EAST_US opening at 25s and MANCHESTER at 15s, but `REGION_PROXIMITY` uses 7_000/8_000 ms and `REGION_INFINITE_AFTER_MS` is 15_000. Please align the comment with the current thresholds, or adjust the constants if the documented timings are what you actually intend.
</issue_to_address>

### Comment 3
<location path="PRIVACY.md" line_range="139" />
<code_context>
+
+- Update the "Last updated" date at the top.
+- Post a notice in Discord and in an Open Collective update.
+- For substantive changes (new categories of data, new transfer destinations, new lawful bases), give 30 days notice before they take effect.
+
+The full version history of this document lives in our public GitHub repository at <https://github.com/openversus> — every change is on the record.
</code_context>
<issue_to_address>
**nitpick (typo):** Change "30 days notice" to "30 days' notice" for correct possessive form.

Using the possessive apostrophe here is the standard grammatical form and would improve the professionalism of the text without changing its meaning.

```suggestion
- For substantive changes (new categories of data, new transfer destinations, new lawful bases), give 30 days' notice before they take effect.
```
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/services/dataDeletionService.ts
Comment thread src/services/regions.ts
Comment thread PRIVACY.md Outdated
tuggernuts1123 and others added 3 commits May 8, 2026 21:58
Three issues flagged on the eu-launch PR:

1. (bug_risk) dataDeletionService.ts wiped `match_to_set:*` with a global
   wildcard during account deletion, which would clobber state for ALL
   active matches — not just the deleted account. Removed the entry from
   the wipe list entirely; those keys aren't keyed by accountId and have
   a short TTL, so they age out naturally. Added a comment forbidding
   future global-wildcard patterns at this site.

2. (nitpick) regions.ts comment on getAllowedRegions described EAST_US
   opening at 25s and MANCHESTER at 15s — leftovers from the original
   draft. Actual constants in REGION_PROXIMITY are 7s / 8s with a
   15s global cutoff via REGION_INFINITE_AFTER_MS. Comment updated to
   match the code; constants unchanged (confirmed correct per Tugger's
   spec earlier).

3. (typo) PRIVACY.md: "30 days notice" → "30 days' notice" (possessive).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The deletion service was running zstd-napi's synchronous compress/
decompress on the main event loop. For a heavy player with hundreds or
thousands of archived matches, that meant 30-90+ seconds of fully blocked
event loop while the deletion ran:

  - Active matches stop receiving rollback inputs → desync
  - Live /access calls hang
  - Matchmaking worker tick stalls
  - Reverse-proxy 504s the admin's request at 30-60s

Fix: isolate the CPU-bound work in a node:worker_threads worker.

  src/workers/anonymizeArchive.worker.ts (new)
    Standalone worker that handles one archive at a time. Receives
    { compressed_data, accountId, oldUsername, anonymizedName }; runs the
    decompress → JSON.parse → walk → JSON.stringify → recompress pipeline;
    posts back { changed, compressed_data? } or { error }. Returns the
    recompressed buffer via postMessage transferList so we don't pay a
    structured-clone copy cost on the way back.

  src/services/dataDeletionService.ts
    New ArchiveAnonymizerWorker class wraps a single Worker for the
    duration of one processDeletion call. Per-archive it: queues a
    message, awaits the response, saves to Mongo if changed. Worker is
    terminated in a finally block so a failed deletion can't leak threads.

    Worker path resolves to .ts in dev (with execArgv "-r @swc-node/register"
    matching how npm start runs the main process) and falls back to .js for
    prod builds. Loud-fail at startup of the deletion if neither exists.

Wallclock duration of a deletion is roughly unchanged (still sequential),
but the main thread stays free the entire time so other server work
proceeds normally.

Trade-offs not addressed here:
  - Admin's HTTP request still waits for the whole loop to finish; for
    extreme accounts (1000+ archives) the request could still 504 at the
    reverse proxy. Current Mongo dump tops out around the low hundreds for
    any single player so this is unlikely in practice. If it becomes a
    real problem, follow-up: respond to the admin after the synchronous
    Mongo deletes, run the archive loop as a background task, expose
    progress via the request's status endpoint.
  - Single worker, no pool. Deletions are rare so parallelism isn't
    valuable; one worker is enough to unblock the main thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small fixes, all cheap; defer the architectural suggestions
(per-player MatchArchive index, two-tier Redis+Mongo rate limiter)
to dedicated work where they can be designed properly.

1. checkRateLimit comment was wrong on two counts: it said "active
   (pending) requests" but the query has no status filter, and it
   said "Redis-backed" but uses Mongo countDocuments. The CODE
   behavior is intentional and correct — counting all statuses
   prevents file→approve→file→approve unbounded loops, and Mongo
   countDocuments on a small indexed collection is microseconds at
   this endpoint's traffic. Just updated the comment to match.

2. notifyDiscord: lowered the "URL not set" log from warn to info.
   Running without a webhook is a valid operator choice, not an
   actionable warning.

3. MatchArchive deletion scan: added a .select() projection to the
   find() so we only hydrate the fields we actually consume
   (match_id, timestamp, compressed_data). Mongoose's save() on a
   projected document only writes dirty paths so the anonymize
   round-trip is unaffected. Added a TODO for the longer-term
   participantIds sidecar field that would turn the O(N_year) scan
   into a selective query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant