fix: bind assignments to authenticated player

This commit is contained in:
Josh Creek
2026-08-31 22:55:22 +01:00
parent 69f1ef6be1
commit 6b3db98327
4 changed files with 10 additions and 6 deletions
+4 -2
View File
@@ -16,7 +16,7 @@ var join_authorisation := ""
var error_message := ""
func apply(payload: Dictionary) -> bool:
func apply(payload: Dictionary, expected_player_id: String = "") -> bool:
for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "join_authorisation"]:
if not payload.has(key):
return _reject("Assignment response is missing " + key)
@@ -25,7 +25,9 @@ func apply(payload: Dictionary) -> bool:
var next_match_id := String(payload["match_id"])
var next_server_id := String(payload["server_id"])
var next_transport := String(payload["transport"])
if next_match_id.is_empty() or next_server_id.is_empty() or String(payload["player_id"]).is_empty() or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or String(payload["expires_at"]).is_empty() or String(payload["join_authorisation"]).is_empty():
var next_player_id := String(payload["player_id"])
var expiry_unix := Time.get_unix_time_from_datetime_string(String(payload["expires_at"]))
if next_match_id.is_empty() or next_server_id.is_empty() or next_player_id.is_empty() or (not expected_player_id.is_empty() and next_player_id != expected_player_id) or int(payload["slot"]) < 0 or int(payload["slot"]) > 5 or int(payload["protocol_version"]) < 1 or (next_transport != "enet" and next_transport != "steam_sdr") or String(payload["expires_at"]).is_empty() or expiry_unix <= Time.get_unix_time_from_system() or String(payload["join_authorisation"]).is_empty():
return _reject("Assignment response contains invalid values")
match_id = next_match_id
server_id = next_server_id
+2 -2
View File
@@ -102,7 +102,7 @@ func fetch_ranked_profile() -> Error:
func fetch_assignment(match_id: String) -> Error:
if match_id.is_empty():
if match_id.is_empty() or player_id.is_empty():
return ERR_INVALID_PARAMETER
return _start_request("assignment", HTTPClient.METHOD_GET, "/v1/assignments/" + match_id, {}, "")
@@ -236,7 +236,7 @@ func _on_request_completed(result: HTTPRequest.Result, response_code: int, _head
request_failed.emit(operation, response_code, ranked_profile.error_message)
return
elif operation == "assignment":
if not assignment.apply(payload):
if not assignment.apply(payload, player_id):
request_failed.emit(operation, response_code, assignment.error_message)
return
request_succeeded.emit(operation, payload)
+3 -1
View File
@@ -5,7 +5,7 @@ const AssignmentState = preload("res://scripts/assignment_state.gd")
func test_assignment_projection_accepts_verified_enet_manifest() -> void:
var assignment := AssignmentState.new()
assert_true(assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 2, "expires_at": "2026-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "join_authorisation": "signed"}), "valid assignment applies")
assert_true(assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 2, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "join_authorisation": "signed"}, "player-1"), "valid assignment applies")
assert_true(assignment.available, "assignment becomes available only after validation")
assert_eq(assignment.transport, "enet", "transport is explicit")
assert_eq(assignment.slot, 2, "slot is preserved")
@@ -17,3 +17,5 @@ func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> voi
assert_true(not assignment.available, "invalid assignment is not exposed")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "udp", "join_authorisation": "signed"}), "unknown transport is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "future", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": ""}), "empty authorisation is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-2", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "wrong player assignment is rejected")
assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2000-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "join_authorisation": "signed"}, "player-1"), "expired assignment is rejected")
+1 -1
View File
@@ -1228,7 +1228,7 @@ the local/CI/community transport, not a silent production fallback.
|---|---|---|
| 8.39 `[D:8.3,8.14,8.17]` | **IN PROGRESS.** `MatchmakingState` now projects queue → proposal → allocation/process-ready/assignment-ready/connect/live plus terminal failure states; autoload `ControlPlaneClient` provides authenticated queue create/recovery/heartbeat/cancel and proposal response requests with idempotency/revision headers; `matchmaking.tscn`/`matchmaking.gd` expose the state and authoritative actions from the main menu; authenticated proposal recovery now reconciles missed proposal events and expires them at read time | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd` and `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary` reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; server-pushed allocation events, wait/latency explanations and Godot runtime verification remain |
| 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read; Godot client projection persists non-secret ticket/proposal state, forces authoritative recovery after restart, and can replay a lost queue-create response with the original ticket/idempotency key | `server/domain/sync.go`, `server/api/service.go`, `service_test.go`, `matchmaking_state.gd` and `control_plane_client.gd` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery, expired-ticket terminal handling, malformed restart snapshots and API-level duplicate-create replay/conflict; authenticated WebSocket transport and live Godot verification remain |
| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` preserve explicit transport, slot and join authorisation without connecting before assignment-ready | `server/api/service.go`, `service_test.go`, `assignment_state.gd` and `test_assignment_state.gd` cover participant/expiry/shape/transport boundaries and assignment recovery; signed manifest-to-player persistence, SDR relay-ticket installation, `hello` join-authorisation wiring, fencing integration and live Godot verification remain |
| 8.41 `[D:7.8,8.9,8.31,8.40]` | **IN PROGRESS.** Authenticated `GET /v1/assignments/{matchId}` now exposes only a validated, player-scoped assignment view; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, preserve explicit transport/slot/join authorisation and do not connect before assignment-ready | `server/api/service.go`, `service_test.go`, `assignment_state.gd` and `test_assignment_state.gd` cover participant/identity/expiry/shape/transport boundaries and assignment recovery; signed manifest-to-player persistence, SDR relay-ticket installation, `hello` join-authorisation wiring, fencing integration and live Godot verification remain |
| 8.42 `[D:8.22,8.23,8.24,8.40]` | **IN PROGRESS.** `RankedProfileState` and `ControlPlaneClient.fetch_ranked_profile()` expose the backend-authoritative rating/RD/volatility/games/tier/provisional/season view; matchmaking UI displays provisional/tier status without client-side rating math | `test_control_plane_client.gd` validates profile shape, numeric safety and provisional display; ranked profile fetch/display, committed revision after reconnect, abandon status and season countdown remain dependent on live auth/backend events and Godot runtime verification |
| 8.43 `[D:8.39,8.40,8.41]` | **IN PROGRESS.** Matchmaking client now distinguishes expired queue recovery, session expiry, missing records and retryable control-plane outages; a 401 clears the in-memory token, emits `session_expired` and disables retry until a new session is configured; terminal messages remain visible and active searches are not falsely failed on transient errors | `MatchmakingState` and `ControlPlaneClient` tests cover explicit expiry and the existing terminal/retry-safe state paths; decline, version mismatch, regional outage retry UI, failed reconnect, duplicate-action recovery and live Godot verification remain |