From fcc5d82763f68ba901717eb66a42c27488c9393e Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 23:00:22 +0100 Subject: [PATCH] feat: expose documented control plane routes --- multiplayer-todo.md | 2 +- server/api/service.go | 111 ++++++++++++++++++++++++++++++++++++- server/api/service_test.go | 64 +++++++++++++++++++++ 3 files changed, 175 insertions(+), 2 deletions(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 2ff7cd5a..3b1e48d1 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1170,7 +1170,7 @@ the local/CI/community transport, not a silent production fallback. |---|---|---| | 8.1 | **DONE.** Add an ADR locking **Go + PostgreSQL + Redis**, provider-portable Kubernetes, Agones, ticketed Hosted Dedicated Server SDR, EU/NA fleets and independently runnable API, matcher, allocator and maintenance roles; keep `README.md`/`docs/TECH_STACK.md` consistent | [`docs/ADR-001-matchmaking-platform.md`](docs/ADR-001-matchmaking-platform.md) names boundaries/rejected alternatives; current docs name the locked stack and replaceable provider; no application code calls a provider allocation API | | 8.2 `[D:8.1]` | **DONE.** Encode the launch SLOs from `docs/MATCHMAKING.md`: RTT, allocation/connect latency, 99.9% allocation/result success, API latency and tick health | [`docs/MATCHMAKING-SLOs.md`](docs/MATCHMAKING-SLOs.md) defines each metric, denominator, percentile/window, owner, alert threshold and release evidence | -| 8.3 `[D:8.1]` | **DONE.** Publish versioned OpenAPI + WebSocket contracts for Steam login/session, profile/rating, queue create/heartbeat/cancel/resume, proposal accept/decline, assignment/status, server registration/roster/result/shutdown | [`server/contracts/v1/`](server/contracts/v1/) contains machine-readable REST/events contracts and dependency-free structural tests; REST resync is specified by the contract | +| 8.3 `[D:8.1]` | **DONE.** Publish versioned OpenAPI + WebSocket contracts for Steam login/session, profile/rating, queue create/heartbeat/cancel/resume, proposal accept/decline, assignment/status, server registration/roster/result/shutdown | [`server/contracts/v1/`](server/contracts/v1/) contains machine-readable REST/events contracts and dependency-free structural tests; REST resync is specified by the contract; `server/api/service.go` also exposes the documented `/api/v1` route names (including server-assigned idempotent queue ticket IDs and DELETE cancellation) alongside the existing client `/v1` routes, covered by `TestDocumentedContractRoutesAdaptToServiceAPI` | | 8.4 `[D:8.3]` | **DONE.** Define opaque IDs, legal queue/match state transitions, revisions and idempotency keys | [`server/contracts/v1/state-transitions.json`](server/contracts/v1/state-transitions.json) locks terminal states, legal edges, stale-revision handling and same-key replay/conflict behavior; contract tests cover the invariants | | 8.5 `[D:8.4]` | **IN PROGRESS.** Initial PostgreSQL migration now defines durable idempotency keys, queue ownership/active-participation fencing, identities, sessions/revocations, ranked seasons, ratings/events, matches/participants, penalties, results, audits and outbox | `server/migrations/0001_initial.sql` and static checks cover the durable tables, uniqueness/check constraints and Redis-as-cache boundary; live PostgreSQL up/rollback/forward migration, serializable adapters and cache-loss repair remain | | 8.6 `[D:8.3,8.4]` | **IN PROGRESS.** Add allocated-mode `ServerConfig` compatibility fields as opt-in defaults | `ServerConfig` now validates allocation mode, match/server IDs, playlist version, client build, future assignment expiry, image digest, transport and EU/NA region; signed-authorisation admission and full manifest/runtime tests remain | diff --git a/server/api/service.go b/server/api/service.go index dc4d5998..ec001e16 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -4,7 +4,10 @@ package api import ( + "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "io" @@ -81,6 +84,14 @@ func (s *Service) Handler() http.Handler { mux.HandleFunc("/v1/assignments/", s.assignment) mux.HandleFunc("/v1/profile/ranked", s.rankedProfile) mux.HandleFunc("/v1/probes/", s.probe) + // The public contract is served below /api/v1. Keep the original /v1 + // routes for the Godot client while exposing the documented names. + mux.HandleFunc("/api/v1/session/steam", s.steamSession) + mux.HandleFunc("/api/v1/profile", s.profile) + mux.HandleFunc("/api/v1/queue/tickets", s.contractQueueCreate) + mux.HandleFunc("/api/v1/queue/tickets/", s.contractQueueMutation) + mux.HandleFunc("/api/v1/proposals/", s.contractProposalMutation) + mux.HandleFunc("/api/v1/assignments/", s.contractAssignment) return mux } @@ -213,8 +224,80 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, toQueueResponse(ticket)) } +func (s *Service) contractQueueCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + s.queueCreate(w, r) + return + } + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes)) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_request") + return + } + var fields map[string]json.RawMessage + if json.Unmarshal(body, &fields) == nil { + // 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. + digest := sha256.Sum256([]byte(r.Header.Get("Authorization") + "\x00" + r.Header.Get("Idempotency-Key"))) + id := hex.EncodeToString(digest[:]) + if _, exists := fields["ticket_id"]; !exists { + fields["ticket_id"] = json.RawMessage(strconv.Quote(id)) + body, _ = json.Marshal(fields) + } + } + r.Body = io.NopCloser(bytes.NewReader(body)) + s.queueCreate(w, r) +} + +func (s *Service) contractQueueMutation(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/queue/tickets/") + parts := strings.Split(path, "/") + if path == "" || len(parts) > 2 || parts[0] == "" || (len(parts) == 2 && parts[1] != "heartbeat") { + writeError(w, http.StatusNotFound, "not_found") + return + } + clone := r.Clone(r.Context()) + clone.URL.Path = "/v1/queue/" + parts[0] + if len(parts) == 2 { + clone.URL.Path += "/heartbeat" + } + if r.Method == http.MethodDelete { + if len(parts) != 1 { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + clone.Method = http.MethodPost + clone.URL.Path += "/cancel" + clone.Header.Set("X-Contract-Delete", "1") + } + s.queueMutation(w, clone) +} + +func (s *Service) contractProposalMutation(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/proposals/") + if path == "" { + writeError(w, http.StatusNotFound, "not_found") + return + } + clone := r.Clone(r.Context()) + clone.URL.Path = "/v1/proposals/" + path + s.proposalMutation(w, clone) +} + +func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v1/assignments/") + if path == "" || strings.Contains(path, "/") { + writeError(w, http.StatusNotFound, "not_found") + return + } + clone := r.Clone(r.Context()) + clone.URL.Path = "/v1/assignments/" + path + s.assignment(w, clone) +} + func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost && r.Method != http.MethodGet { + if r.Method != http.MethodPost && r.Method != http.MethodGet && r.Method != http.MethodDelete { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } @@ -279,6 +362,10 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) { writeDomainError(w, err) return } + if r.Header.Get("X-Contract-Delete") == "1" { + w.WriteHeader(http.StatusNoContent) + return + } writeJSON(w, http.StatusOK, toQueueResponse(ticket)) } @@ -387,6 +474,28 @@ type rankedProfileResponse struct { SeasonID string `json:"season_id,omitempty"` } +func (s *Service) profile(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + playerID, ok := s.authenticate(w, r) + if !ok { + return + } + profile, exists := s.RankedProfiles[playerID] + if !exists { + writeError(w, http.StatusNotFound, "not_found") + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "player_id": playerID, + "rating": profile.Value, + "rd": profile.RD, + "provisional": domain.RankedIsProvisional(profile), + }) +} + func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") diff --git a/server/api/service_test.go b/server/api/service_test.go index 82a1f282..fa4da1ac 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -97,6 +97,70 @@ func TestAuthenticatedQueueAPIUsesServerCandidateAndRevisionedMutations(t *testi _ = response.Body.Close() } +func TestDocumentedContractRoutesAdaptToServiceAPI(t *testing.T) { + now := time.Unix(1000, 0).UTC() + backend := &queueBackendSpy{} + service := &Service{ + SessionBackend: &sessionBackendSpy{}, + QueueBackend: backend, + Now: func() time.Time { return now }, + } + server := httptest.NewServer(service.Handler()) + defer server.Close() + auth := "Bearer session-1:token-1" + create, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets", strings.NewReader(`{"playlist":"casual","client_build":"build-1","protocol_version":1}`)) + if err != nil { + t.Fatal(err) + } + create.Header.Set("Authorization", auth) + create.Header.Set("Idempotency-Key", "contract-create-key-123456") + response, err := http.DefaultClient.Do(create) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusCreated || backend.createCalls != 1 { + t.Fatalf("create status = %d, calls = %d", response.StatusCode, backend.createCalls) + } + var ticket queueResponse + if err := json.NewDecoder(response.Body).Decode(&ticket); err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if ticket.TicketID == "" { + t.Fatal("contract adapter did not assign a ticket id") + } + heartbeat, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/queue/tickets/"+ticket.TicketID+"/heartbeat", nil) + if err != nil { + t.Fatal(err) + } + heartbeat.Header.Set("Authorization", auth) + heartbeat.Header.Set("Idempotency-Key", "contract-heartbeat-key-123") + heartbeat.Header.Set("If-Match-Revision", "0") + response, err = http.DefaultClient.Do(heartbeat) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusOK || backend.heartbeatCalls != 1 { + t.Fatalf("heartbeat status = %d, calls = %d", response.StatusCode, backend.heartbeatCalls) + } + cancel, err := http.NewRequest(http.MethodDelete, server.URL+"/api/v1/queue/tickets/"+ticket.TicketID, nil) + if err != nil { + t.Fatal(err) + } + cancel.Header.Set("Authorization", auth) + cancel.Header.Set("Idempotency-Key", "contract-cancel-key-123456") + cancel.Header.Set("If-Match-Revision", "0") + response, err = http.DefaultClient.Do(cancel) + if err != nil { + t.Fatal(err) + } + _ = response.Body.Close() + if response.StatusCode != http.StatusNoContent || backend.cancelCalls != 1 { + t.Fatalf("cancel status = %d, calls = %d", response.StatusCode, backend.cancelCalls) + } +} + func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) { service := &Service{Sessions: domain.NewSessionStore(), Queue: domain.NewQueue(), Candidate: func(string, string) (domain.Candidate, error) { return domain.Candidate{}, nil }} server := httptest.NewServer(service.Handler())