mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: expose validated assignment endpoints
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+1
-1
@@ -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 |
|
||||
|
||||
|
||||
+15
-1
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"]}}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user