diff --git a/multiplayer-next.md b/multiplayer-next.md index 335ac401..b4b53b2f 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -1615,6 +1615,8 @@ Queue responses now validate the complete published shape before projection: opa The public `/api/v1` route adapters now reject non-opaque queue, proposal, assignment, and server path identifiers before delegating to the legacy handlers; adversarial route tests cover short and separator-bearing IDs. +The public queue adapter also rejects an explicitly supplied short or unsafe `ticket_id`; omitted IDs continue to be deterministically server-assigned for idempotent retries. + 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/service.go b/server/api/service.go index af6fb32f..d0aa5b01 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -453,6 +453,13 @@ func (s *Service) contractQueueCreate(w http.ResponseWriter, r *http.Request) { } var fields map[string]json.RawMessage if json.Unmarshal(body, &fields) == nil { + if rawTicketID, exists := fields["ticket_id"]; exists { + var ticketID string + if json.Unmarshal(rawTicketID, &ticketID) != nil || !controlPlaneResourceIDRE.MatchString(ticketID) { + writeError(w, http.StatusBadRequest, "invalid_request") + return + } + } // Ticket IDs are server-assigned for the public contract. Deriving one // from the authenticated request's idempotency material makes retries // converge on the same domain command without persisting adapter state. diff --git a/server/api/service_test.go b/server/api/service_test.go index cb6e6ebd..97af83b3 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -281,6 +281,18 @@ func TestDocumentedContractRoutesRejectNonOpaqueResourceIDs(t *testing.T) { service := &Service{} server := httptest.NewServer(service.Handler()) defer server.Close() + request, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets", strings.NewReader(`{"ticket_id":"short","playlist":"casual","client_build":"build-1","protocol_version":1}`)) + if err != nil { + t.Fatal(err) + } + if response, requestErr := http.DefaultClient.Do(request); requestErr != nil { + t.Fatal(requestErr) + } else { + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("short supplied ticket id status = %d, want 400", response.StatusCode) + } + response.Body.Close() + } paths := []string{ "/api/v1/queue/tickets/short/heartbeat", "/api/v1/proposals/proposal/unsafe/accept",