From 467c25b20c1dea05e99e59e9f0e506c241e8d2c1 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:17:39 +0100 Subject: [PATCH] feat: expose validated assignment endpoints --- Game/scripts/assignment_state.gd | 19 ++++++++++++++++--- Game/tests/cases/test_assignment_state.gd | 4 +++- multiplayer-todo.md | 2 +- server/api/service.go | 16 +++++++++++++++- server/api/service_test.go | 15 ++++++++++++++- server/api/store_adapters.go | 1 + server/contracts/v1/openapi.json | 2 +- 7 files changed, 51 insertions(+), 8 deletions(-) diff --git a/Game/scripts/assignment_state.gd b/Game/scripts/assignment_state.gd index 5b10e1ca..0b5c5703 100644 --- a/Game/scripts/assignment_state.gd +++ b/Game/scripts/assignment_state.gd @@ -12,22 +12,24 @@ var slot := -1 var expires_at := "" var protocol_version := 0 var transport := "" +var endpoint := "" var join_authorisation := "" var error_message := "" 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"]: + for key in ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"]: if not payload.has(key): return _reject("Assignment response is missing " + key) - if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not payload["slot"] is int or not payload["expires_at"] is String or not payload["protocol_version"] is int or not payload["transport"] is String or not payload["join_authorisation"] is String: + if not payload["match_id"] is String or not payload["server_id"] is String or not payload["player_id"] is String or not payload["slot"] is int or not payload["expires_at"] is String or not payload["protocol_version"] is int or not payload["transport"] is String or not payload["endpoint"] is String or not payload["join_authorisation"] is String: return _reject("Assignment response contains invalid types") var next_match_id := String(payload["match_id"]) var next_server_id := String(payload["server_id"]) var next_transport := String(payload["transport"]) + var next_endpoint := String(payload["endpoint"]) 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(): + 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 not _valid_endpoint(next_endpoint) 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 @@ -35,12 +37,23 @@ func apply(payload: Dictionary, expected_player_id: String = "") -> bool: expires_at = String(payload["expires_at"]) protocol_version = int(payload["protocol_version"]) transport = next_transport + endpoint = next_endpoint join_authorisation = String(payload["join_authorisation"]) available = true error_message = "" return true +static func _valid_endpoint(value: String) -> bool: + if value.is_empty() or value.contains("/") or value.contains("?") or value.contains("#"): + return false + var separator := value.rfind(":") + if separator <= 0 or separator >= value.length() - 1: + return false + var port := value.substr(separator + 1) + return port.is_valid_int() and int(port) >= 1 and int(port) <= 65535 + + func _reject(reason: String) -> bool: available = false error_message = reason diff --git a/Game/tests/cases/test_assignment_state.gd b/Game/tests/cases/test_assignment_state.gd index 7a8ee082..2c64cab8 100644 --- a/Game/tests/cases/test_assignment_state.gd +++ b/Game/tests/cases/test_assignment_state.gd @@ -5,10 +5,11 @@ 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": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "enet", "join_authorisation": "signed"}, "player-1"), "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", "endpoint": "127.0.0.1:30001", "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") + assert_eq(assignment.endpoint, "127.0.0.1:30001", "endpoint is preserved") func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> void: @@ -19,3 +20,4 @@ func test_assignment_projection_rejects_wrong_shape_or_unsafe_transport() -> voi 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") + assert_true(not assignment.apply({"match_id": "match-1", "server_id": "server-1", "player_id": "player-1", "slot": 0, "expires_at": "2099-08-31T12:00:00Z", "protocol_version": 1, "transport": "steam_sdr", "endpoint": "127.0.0.1", "join_authorisation": "signed"}, "player-1"), "unsafe endpoint is rejected") diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 1e077d1f..0a1e8a0c 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -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; the Go API publishes targeted authenticated revisioned queue/proposal/assignment events and the Godot client consumes state/proposal/assignment events, fetching the authoritative assignment after assignment readiness; the API can now use the durable participant-scoped proposal provider for both recovery and accept/decline mutations | `test_matchmaking_state.gd`, `test_control_plane_client.gd`, `test_matchmaking_ui.gd`, `TestProposalRecoveryIsParticipantScopedAndExpiresAtReadBoundary`, `TestAuthenticatedWebSocketDeliversOnlyTargetedRevisionedEvents`, `TestStateChangingAPIActionsPublishTargetedEvents`, `TestProposalRecoveryUsesDurableBackendAndRemainsParticipantScoped` and store proposal SQL tests reject stale/gapped/conflicting updates, validate endpoint/token/payload normalization, preserve idempotent duplicates, and guarantee visible phase/terminal copy; wiring durable allocation events into the outbox, 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; the Go API now exposes an authenticated `/v1/events` WebSocket with bounded per-player queues, strict upgrade/vocabulary validation and REST-resync-safe slow-client failure; Godot `ControlPlaneClient` can connect to the stream, consume validated events, automatically reconnect with bounded backoff, and trigger REST recovery on projection gaps and stream return; `OutboxDispatcher` now provides ordered at-least-once delivery after durable commit; assignment recovery events carry the durable assignment revision without leaking it into REST JSON | `server/domain/sync.go`, `server/api/events.go`, `server/api/service.go`, `server/store/outbox.go`, `service_test.go`, `outbox_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, API-level duplicate-create replay/conflict, authenticated handshake/key rejection, targeted event delivery, exactly-once slow-subscriber closure, invalid-event rejection, delivery-before-ack failure ordering and assignment event/response revision separation; wiring the dispatcher to a production WebSocket/Redis worker 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; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `test_assignment_state.gd`, `server/migrations/0002_assignments.sql` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation and signed-claim binding; direct API caller wiring, SDR relay-ticket installation, `hello` join-authorisation wiring, fencing integration and live Godot/PostgreSQL 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 including the hosted endpoint; Godot `AssignmentState`/`ControlPlaneClient.fetch_assignment()` bind the response to the authenticated player, recheck expiry, validate and retain the endpoint, preserve explicit transport/slot/join authorisation and do not connect before assignment-ready; migration 0002 and the Go store adapter now persist/recover the complete player-scoped assignment projection with conflict-safe identical replay; `AssignmentProviderFromStore` wires that durable projection into the API injection point; `SaveAssignments` publishes a complete signed roster atomically instead of allowing partial player visibility; `SaveVerifiedAssignmentRoster` rechecks signed claims before deriving player rows | `server/api/service.go`, `store_adapters.go`, `service_test.go`, `assignment_state.gd`, `test_assignment_state.gd`, `server/contracts/v1/openapi.json`, `server/migrations/0002_assignments.sql` and `server/store/assignment_sql.go` cover participant/identity/expiry/shape/transport/endpoint boundaries, player-scoped schema keys, expiry-filtered reads, assignment upsert conflict handling, atomic batch validation and signed-claim binding; direct client connect caller, SDR relay-ticket installation, `hello` join-authorisation wiring, fencing integration and live Godot/PostgreSQL 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 | diff --git a/server/api/service.go b/server/api/service.go index 13492242..7cc8f6b7 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "io" + "net" "net/http" "strconv" "strings" @@ -58,6 +59,7 @@ type AssignmentView struct { ProtocolVersion int `json:"protocol_version"` Transport string `json:"transport"` JoinAuthorisation string `json:"join_authorisation"` + Endpoint string `json:"endpoint"` // Revision is routing metadata for the event stream, not part of the v1 // assignment response. Keeping it alongside the durable view prevents the // REST recovery boundary from emitting a synthetic revision zero. @@ -509,7 +511,7 @@ func (s *Service) assignment(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "not_found") return } - if view.ServerID == "" || view.Slot < 0 || view.Slot > 5 || view.ProtocolVersion < 1 || (view.Transport != "enet" && view.Transport != "steam_sdr") || view.JoinAuthorisation == "" || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) { + if view.ServerID == "" || view.Slot < 0 || view.Slot > 5 || view.ProtocolVersion < 1 || (view.Transport != "enet" && view.Transport != "steam_sdr") || view.JoinAuthorisation == "" || !validAssignmentEndpoint(view.Endpoint) || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) { writeError(w, http.StatusServiceUnavailable, "assignment_unavailable") return } @@ -517,6 +519,18 @@ func (s *Service) assignment(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, view) } +func validAssignmentEndpoint(endpoint string) bool { + if endpoint == "" || strings.ContainsAny(endpoint, "/?#") { + return false + } + host, portText, err := net.SplitHostPort(endpoint) + if err != nil || host == "" { + return false + } + port, err := strconv.Atoi(portText) + return err == nil && port >= 1 && port <= 65535 +} + func assignmentChangedEvent(view AssignmentView, now time.Time) ControlPlaneEvent { return ControlPlaneEvent{Event: "assignment_changed", Revision: view.Revision, ResourceID: view.MatchID, OccurredAt: now, MatchID: view.MatchID, ServerID: view.ServerID, PlayerID: view.PlayerID} } diff --git a/server/api/service_test.go b/server/api/service_test.go index 2ba78fc5..20a6452a 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -939,7 +939,7 @@ func TestAssignmentRecoveryIsPlayerScopedAndRejectsExpiredOrMismatchedViews(t *t } current := now service := &Service{Sessions: sessions, Now: func() time.Time { return current }, Assignment: func(_ context.Context, _ string, matchID string, _ time.Time) (AssignmentView, error) { - return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", JoinAuthorisation: "signed-join"}, nil + return AssignmentView{MatchID: matchID, ServerID: "server-1", PlayerID: "player-a", Slot: 2, ExpiresAt: now.Add(time.Minute), ProtocolVersion: 1, Transport: "enet", Endpoint: "127.0.0.1:30001", JoinAuthorisation: "signed-join"}, nil }} server := httptest.NewServer(service.Handler()) defer server.Close() @@ -999,3 +999,16 @@ func TestAssignmentEventUsesAuthoritativeRevisionWithoutChangingResponseShape(t t.Fatalf("assignment response leaked event revision: %s", payload) } } + +func TestAssignmentEndpointValidationRejectsAmbiguousOrUnsafeEndpoints(t *testing.T) { + for _, endpoint := range []string{"", "127.0.0.1", "127.0.0.1:0", "127.0.0.1:70000", "https://127.0.0.1:1", "127.0.0.1:1/path"} { + if validAssignmentEndpoint(endpoint) { + t.Fatalf("unsafe endpoint accepted: %q", endpoint) + } + } + for _, endpoint := range []string{"127.0.0.1:1", "example.invalid:65535", "[2001:db8::1]:31001"} { + if !validAssignmentEndpoint(endpoint) { + t.Fatalf("valid endpoint rejected: %q", endpoint) + } + } +} diff --git a/server/api/store_adapters.go b/server/api/store_adapters.go index 8de07915..65eb3012 100644 --- a/server/api/store_adapters.go +++ b/server/api/store_adapters.go @@ -27,6 +27,7 @@ func AssignmentProviderFromStore(db *sql.DB) AssignmentProvider { ProtocolVersion: assignment.ProtocolVersion, Transport: assignment.Transport, JoinAuthorisation: assignment.JoinAuthorisation, + Endpoint: assignment.Endpoint, Revision: assignment.Revision, }, nil } diff --git a/server/contracts/v1/openapi.json b/server/contracts/v1/openapi.json index e2f1be13..9ecb8429 100644 --- a/server/contracts/v1/openapi.json +++ b/server/contracts/v1/openapi.json @@ -81,7 +81,7 @@ "QueueTicket": {"type": "object", "required": ["ticket_id", "player_id", "playlist", "state", "revision", "enqueued_at", "expires_at"], "additionalProperties": false, "properties": {"ticket_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "playlist": {"type": "string", "enum": ["casual", "ranked"]}, "state": {"$ref": "#/components/schemas/QueueState"}, "revision": {"type": "integer", "minimum": 0}, "enqueued_at": {"type": "string", "format": "date-time"}, "expires_at": {"type": "string", "format": "date-time"}}}, "QueueState": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}, "Proposal": {"type": "object", "required": ["proposal_id", "revision", "state", "expires_at", "participants"], "additionalProperties": false, "properties": {"proposal_id": {"$ref": "#/components/schemas/OpaqueId"}, "revision": {"type": "integer", "minimum": 0}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}, "expires_at": {"type": "string", "format": "date-time"}, "participants": {"type": "array", "minItems": 2, "items": {"$ref": "#/components/schemas/OpaqueId"}}}}, - "Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "join_authorisation"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "server_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}, "expires_at": {"type": "string", "format": "date-time"}, "protocol_version": {"type": "integer", "minimum": 1}, "transport": {"type": "string", "enum": ["steam_sdr", "enet"]}, "join_authorisation": {"type": "string"}}}, + "Assignment": {"type": "object", "required": ["match_id", "server_id", "player_id", "slot", "expires_at", "protocol_version", "transport", "endpoint", "join_authorisation"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "server_id": {"$ref": "#/components/schemas/OpaqueId"}, "player_id": {"$ref": "#/components/schemas/OpaqueId"}, "slot": {"type": "integer", "minimum": 0, "maximum": 5}, "expires_at": {"type": "string", "format": "date-time"}, "protocol_version": {"type": "integer", "minimum": 1}, "transport": {"type": "string", "enum": ["steam_sdr", "enet"]}, "endpoint": {"type": "string", "minLength": 3, "maxLength": 256}, "join_authorisation": {"type": "string"}}}, "ServerRegistration": {"type": "object", "required": ["match_id", "protocol_version", "image_digest", "assignment_ready"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "protocol_version": {"type": "integer", "minimum": 1}, "image_digest": {"type": "string", "pattern": "^sha256:[a-f0-9]{64}$"}, "assignment_ready": {"type": "boolean"}}}, "MatchResult": {"type": "object", "required": ["match_id", "result_nonce", "score", "integrity_state"], "additionalProperties": false, "properties": {"match_id": {"$ref": "#/components/schemas/OpaqueId"}, "result_nonce": {"type": "string", "minLength": 16, "maxLength": 128}, "score": {"type": "object", "required": ["team_0", "team_1"], "additionalProperties": false, "properties": {"team_0": {"type": "integer", "minimum": 0}, "team_1": {"type": "integer", "minimum": 0}}}, "integrity_state": {"type": "string", "enum": ["CERTIFIED", "SUPPRESSED", "REVIEW"]}}} }