diff --git a/multiplayer-next.md b/multiplayer-next.md index 3e8a5e86..fb4f1931 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1591,6 +1591,8 @@ Ranked profile projection now enforces the OpenAPI tier enum, rejecting unknown Assignment projections and assignment-changed events now enforce the published opaque-ID shape for match, server, and player identifiers; short or unsafe IDs fail closed. +The WebSocket contract and Go event hub now enforce opaque match and server IDs on assignment notifications, keeping server publication aligned with the Godot client validator. + Signed MatchNet claims now also require exact JSON string/integer types for every identity, protocol, expiry, slot, team, and generation field; string-number coercion is rejected before canonical signature verification. Presentation progress: a shared `Game/themes/cosmic_clash_theme.tres` now gives the menu, lobby, matchmaking, and settings surfaces consistent button, input, option, and label styling. The custom-font portion of `TODO.md` remains open until a distributable font asset is selected. diff --git a/server/api/events.go b/server/api/events.go index c3a844d5..cc4caa26 100644 --- a/server/api/events.go +++ b/server/api/events.go @@ -131,7 +131,7 @@ func validateControlPlaneEvent(event ControlPlaneEvent) error { return errors.New("invalid proposal-changed event") } case "assignment_changed": - if event.MatchID == "" || event.ServerID == "" { + if !controlPlaneResourceIDRE.MatchString(event.MatchID) || !controlPlaneResourceIDRE.MatchString(event.ServerID) { return errors.New("invalid assignment-changed event") } case "error": diff --git a/server/api/service_test.go b/server/api/service_test.go index f5b2f975..c791142b 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -437,6 +437,8 @@ func TestEventHubRejectsEventsOutsideTheV1Vocabulary(t *testing.T) { {Event: "state_changed", State: "NOT_A_STATE", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "proposal_changed", State: "LIVE", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "assignment_changed", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, + {Event: "assignment_changed", MatchID: "match-1", ServerID: "server-1", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, + {Event: "assignment_changed", MatchID: "match_1234567890", ServerID: "server/unsafe", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, {Event: "error", Code: "SECRET_LEAK", Revision: base.Revision, ResourceID: base.ResourceID, OccurredAt: base.OccurredAt, PlayerID: base.PlayerID}, } for _, event := range invalid { diff --git a/server/contracts/v1/websocket-events.json b/server/contracts/v1/websocket-events.json index 22bdaa62..725639e6 100644 --- a/server/contracts/v1/websocket-events.json +++ b/server/contracts/v1/websocket-events.json @@ -9,10 +9,11 @@ {"$ref": "#/$defs/error"} ], "$defs": { - "envelope": {"type": "object", "required": ["event", "revision", "resource_id", "occurred_at"], "properties": {"revision": {"type": "integer", "minimum": 0}, "resource_id": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, "occurred_at": {"type": "string", "format": "date-time"}}}, + "opaqueId": {"type": "string", "pattern": "^[A-Za-z0-9_-]{16,128}$"}, + "envelope": {"type": "object", "required": ["event", "revision", "resource_id", "occurred_at"], "properties": {"revision": {"type": "integer", "minimum": 0}, "resource_id": {"$ref": "#/$defs/opaqueId"}, "occurred_at": {"type": "string", "format": "date-time"}}}, "stateChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "state_changed"}, "state": {"type": "string", "enum": ["QUEUED", "PROPOSED", "ACCEPTED", "ALLOCATING", "PROCESS_READY", "ASSIGNMENT_READY", "ASSIGNED", "CONNECTING", "LIVE", "RESULT_PENDING", "COMPLETED", "CANCELLED", "EXPIRED", "FAILED"]}}, "required": ["event", "state"]}]}, "proposalChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "proposal_changed"}, "state": {"type": "string", "enum": ["OPEN", "ACCEPTED", "DECLINED", "EXPIRED", "CANCELLED"]}}, "required": ["event", "state"]}]}, - "assignmentChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "assignment_changed"}, "match_id": {"type": "string"}, "server_id": {"type": "string"}}, "required": ["event", "match_id", "server_id"]}]}, + "assignmentChanged": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "assignment_changed"}, "match_id": {"$ref": "#/$defs/opaqueId"}, "server_id": {"$ref": "#/$defs/opaqueId"}}, "required": ["event", "match_id", "server_id"]}]}, "error": {"allOf": [{"$ref": "#/$defs/envelope"}, {"type": "object", "properties": {"event": {"const": "error"}, "code": {"type": "string", "enum": ["REVISION_GAP", "NOT_AUTHORISED", "INVALID_STATE", "RATE_LIMITED"]}}, "required": ["event", "code"]}]} } }