From 8d1b407bb025896d634e97686bc2d0c32598fc48 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:17:15 +0100 Subject: [PATCH] feat: add authenticated proposal API --- multiplayer-todo.md | 2 +- server/api/service.go | 64 +++++++++++++++++++++++++++++++++++--- server/api/service_test.go | 40 ++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) diff --git a/multiplayer-todo.md b/multiplayer-todo.md index fd9fe0f7..5248a9bf 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1194,7 +1194,7 @@ the local/CI/community transport, not a silent production fallback. | 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, server-owned candidate resolution, bounded/strict JSON input, cache loss and atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain | | 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; Steam coordinator and regional probe adapters remain | | 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures remain | -| 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation | `server/domain/proposal.go` and adversarial fixtures cover partial/unanimous response, expiry, replay/conflict and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain | +| 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API now exposes revisioned accept/decline mutations | `server/domain/proposal.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain | | 8.18 `[D:8.5,8.14,8.17]` | **IN PROGRESS.** Go store layer defines PostgreSQL SERIALIZABLE whole-transaction retries and queue candidate/proposal claim SQL using `FOR UPDATE SKIP LOCKED` plus durable uniqueness/revision fences | `server/store/serializable.go` and tests cover retry classification and claim-boundary invariants; live PostgreSQL adapter/row decoding, Redis candidate index/repair, worker-failure and concurrent two-matcher integration tests remain | | 8.19 `[D:8.18]` | **IN PROGRESS.** Pure Go casual lineup requires 2–6 humans with at least one per team, fills missing slots with explicit bots, permits kickoff-only bot-slot backfill and assigns no backfill penalty/rating update | `server/domain/casual.go` covers both-team minimum, bot shape, live-play rejection and zero-penalty backfill; queue candidate selection, opt-in 10 s backfill proposals, reconnect/leave penalties and live integration remain | | 8.20 `[D:8.18]` | **IN PROGRESS.** Pure Go ranked admission requires six unique verified solo humans, rejects bots/backfill/parties, and allows only random-enabled non-elevated arenas | `server/domain/ranked.go` covers count, identity, party, bot/backfill and arena eligibility rejection; `ArenaRegistry` integration, proposal/allocation wiring and innocent-ticket restoration remain | diff --git a/server/api/service.go b/server/api/service.go index 413a6c36..50848cdf 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -10,6 +10,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" @@ -20,10 +21,12 @@ const maxBodyBytes = 8 << 10 type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error) type Service struct { - Sessions *domain.SessionStore - Queue *domain.Queue - Candidate CandidateProvider - Now func() time.Time + Sessions *domain.SessionStore + Queue *domain.Queue + Candidate CandidateProvider + Now func() time.Time + Proposals map[string]*domain.Proposal + proposalMu sync.Mutex } func (s *Service) Handler() http.Handler { @@ -31,6 +34,7 @@ func (s *Service) Handler() http.Handler { mux.HandleFunc("/healthz", s.health) mux.HandleFunc("/v1/queue", s.queueCreate) mux.HandleFunc("/v1/queue/", s.queueMutation) + mux.HandleFunc("/v1/proposals/", s.proposalMutation) return mux } @@ -132,6 +136,54 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, toQueueResponse(ticket)) } +type proposalResponse struct { + ProposalID string `json:"proposal_id"` + Playlist string `json:"playlist"` + State string `json:"state"` + Revision uint64 `json:"revision"` + ExpiresAt time.Time `json:"expires_at"` + Participants []domain.ProposalParticipant `json:"participants"` +} + +func (s *Service) proposalMutation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + playerID, ok := s.authenticate(w, r) + if !ok { + return + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/proposals/"), "/") + if len(parts) != 2 || parts[0] == "" || (parts[1] != "accept" && parts[1] != "decline") { + writeError(w, http.StatusNotFound, "not_found") + return + } + key := r.Header.Get("Idempotency-Key") + if len(key) < 16 || len(key) > 128 { + writeError(w, http.StatusBadRequest, "invalid_idempotency_key") + return + } + revision, err := strconv.ParseUint(r.Header.Get("If-Match-Revision"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_revision") + return + } + s.proposalMu.Lock() + defer s.proposalMu.Unlock() + proposal, exists := s.Proposals[parts[0]] + if !exists || proposal == nil { + writeError(w, http.StatusNotFound, "not_found") + return + } + updated, err := proposal.Respond(playerID, key, parts[1] == "accept", revision, s.now()) + if err != nil { + writeDomainError(w, err) + return + } + writeJSON(w, http.StatusOK, toProposalResponse(updated)) +} + func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { if s.Sessions == nil { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") @@ -182,6 +234,10 @@ func toQueueResponse(ticket domain.QueueTicket) queueResponse { return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, State: string(ticket.State), Revision: ticket.Revision, EnqueuedAt: ticket.EnqueuedAt, ExpiresAt: ticket.ExpiresAt} } +func toProposalResponse(proposal domain.Proposal) proposalResponse { + return proposalResponse{ProposalID: proposal.ProposalID, Playlist: string(proposal.Playlist), State: string(proposal.State), Revision: proposal.Revision, ExpiresAt: proposal.ExpiresAt, Participants: proposal.Participants} +} + func writeDomainError(w http.ResponseWriter, err error) { switch { case errors.Is(err, domain.ErrPlayerQueued), errors.Is(err, domain.ErrConflict), errors.Is(err, domain.ErrStaleRevision): diff --git a/server/api/service_test.go b/server/api/service_test.go index a59bb7c5..1cf9042e 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -111,3 +111,43 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) { } _ = response.Body.Close() } + +func TestAuthenticatedProposalAPIUsesRevisionAndIdempotencyPolicy(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + session, token, err := sessions.Issue("player-a", time.Hour, now) + if err != nil { + t.Fatal(err) + } + proposal, err := domain.NewProposal("proposal-123456789", domain.Casual, []string{"player-a", "player-b"}, now) + if err != nil { + t.Fatal(err) + } + service := &Service{Sessions: sessions, Proposals: map[string]*domain.Proposal{proposal.ProposalID: &proposal}, Now: func() time.Time { return now }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/proposals/"+proposal.ProposalID+"/accept", nil) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + req.Header.Set("Idempotency-Key", "proposal-response-123456") + req.Header.Set("If-Match-Revision", "0") + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK { + t.Fatalf("proposal accept status = %d", response.StatusCode) + } + _ = response.Body.Close() + req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/proposals/"+proposal.ProposalID+"/accept", nil) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + req.Header.Set("Idempotency-Key", "proposal-response-654321") + req.Header.Set("If-Match-Revision", "0") + response, err = http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusConflict { + t.Fatalf("stale proposal response status = %d", response.StatusCode) + } + _ = response.Body.Close() +}