EU launch - #37
Open
tuggernuts1123 wants to merge 8 commits into
Open
Conversation
…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>
Reviewer's GuideImplements 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 routingsequenceDiagram
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
Sequence diagram for self-serve data deletion request lifecyclesequenceDiagram
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
ER diagram for data deletion domain and affected entitieserDiagram
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
Updated class diagram for regions and data deletion domainclassDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Failed to generate code suggestions for PR |
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
processDeletion, the Redis wipe loop includes the non-account-scoped patternmatch_to_set:*despite the comment saying it should be skipped, which means processing a single deletion will delete allmatch_to_setkeys; consider removing this pattern or scoping it to the specific account. - The match archive anonymization walks every
MatchArchivefrom 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What's shipping
1. Region-aware matchmaking + multi-host rollback routing (
05e93da)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_OVERRIDESfor cross-region pair routing./accessviageoip-lite— player's region is classified at login and stored onconnections:{id}.region.EAST_USopens toMANCHESTERat 7s,MANCHESTERopens toEAST_USat 8s, all-region open at 15s.handleMatchFoundnow picks the rollback host IP based on the matched players' regions (getMatchServerRegion→rollbackHostIpForRegion); cross-Atlantic matches default toEAST_USper overrides table.region: "MVSI"stub in matchmaking tickets with the real classified region.UDP_SERVER_IP_EAST_US,UDP_SERVER_IP_MANCHESTER. Both default to empty and fall back toUDP_SERVER_IP— single-region operation is byte-identical to today untilUDP_SERVER_IP_MANCHESTERis populated.Reference port from
multiversuskoth/mvs-http-server'smatchmaking.matching.ts; the original is preserved atreference/upstream/matchmaking.matching.tsfor 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.playertestersby exact name + IP, returns up to 10 with identity-strength badges (steam/epic/hworno identity); click row → highlight + auto-fill matched-ID + auto-save.src/services/dataDeletionService.ts):playertesters._id,eloratings.account_id,playerstats.account_id,cosmetics.account_id,friendlists.accountId; pullsaccountIdfrom other users' friend lists; sweepsconnections:{id}*,player:{id}:*,player_lobby,player_ranked_set,bot_config, etc. from Redis.match_archivesfor the last 12 months — decompresses the zstd JSON, replacesaccountId/ username withdeleted_user_<8hex>, recompresses. Co-player records stay intact (Art 17(3) carve-out for statistical purposes / other parties' rights)./adminlisting both tools (banner + data deletion).3. Privacy policy (
6659dff)PRIVACY.mdat the repo root — single source of truth, GitHub-rendered for anyone browsing the repo./privacyand/privacy-policyweb routes — same markdown rendered viamarked./homeand/delete-account.README.mdso it's visible at the install / setup boundary.4. Custom lobby leave→change-mode fix (
2b50984,2bc16e1)Pre-existing bug, related but standalone:
menu → custom lobby → leave → change mode to 2v2hung 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}). Subsequentset_lobby_modewould read those keys, find the previous party-lobby ID (or nothing), and silently no-op.2b50984— seeds all three legacy keys atcreatePartyLobbyconstruction 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 overwritingplayer_lobbyand breaking thecreate_party_lobbySSC'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:
Bug Fixes:
Enhancements:
Documentation:
Chores: