// Package api exposes the small authenticated HTTP boundary around domain // policies. Persistent adapters can replace the in-memory dependencies without // changing request authentication or validation rules. package api import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "sync" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) const maxBodyBytes = 8 << 10 type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error) type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) type QueueBackend interface { Create(context.Context, string, string, string, domain.QueueSpec, time.Time) (domain.QueueTicket, error) Heartbeat(context.Context, string, string, string, uint64, time.Time) (domain.QueueTicket, error) Cancel(context.Context, string, string, string, uint64, time.Time) (domain.QueueTicket, error) Get(context.Context, string, string, time.Time) (domain.QueueTicket, error) } type SessionBackend interface { Authenticate(context.Context, string, string, time.Time) (domain.Session, error) } type SteamLoginProvider interface { Authenticate(context.Context, string, time.Time) (domain.VerifiedIdentity, error) } type SessionIssuer interface { Issue(context.Context, string, time.Duration, time.Time) (domain.Session, string, error) } type ProposalBackend interface { Get(context.Context, string, string, time.Time) (domain.Proposal, error) } type AssignmentView struct { MatchID string `json:"match_id"` ServerID string `json:"server_id"` PlayerID string `json:"player_id"` Slot int `json:"slot"` ExpiresAt time.Time `json:"expires_at"` ProtocolVersion int `json:"protocol_version"` Transport string `json:"transport"` JoinAuthorisation string `json:"join_authorisation"` } type AssignmentProvider func(context.Context, string, string, time.Time) (AssignmentView, error) type Service struct { Sessions *domain.SessionStore SessionBackend SessionBackend SessionIssuer SessionIssuer SteamLogin SteamLoginProvider Queue *domain.Queue Candidate CandidateProvider CandidateV2 CandidateProviderV2 QueueBackend QueueBackend Probe ProbeProvider Assignment AssignmentProvider Now func() time.Time Proposals map[string]*domain.Proposal ProposalBackend ProposalBackend RankedProfiles map[string]domain.RankedProfile TierPolicy domain.TierPolicy RateLimiter *RateLimiter proposalMu sync.Mutex eventsMu sync.Mutex events *eventHub } func (s *Service) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.health) mux.HandleFunc("/v1/session/steam", s.steamSession) mux.HandleFunc("/v1/queue", s.queueCreate) mux.HandleFunc("/v1/queue/", s.queueMutation) mux.HandleFunc("/v1/proposals/", s.proposalMutation) mux.HandleFunc("/v1/assignments/", s.assignment) mux.HandleFunc("/v1/profile/ranked", s.rankedProfile) mux.HandleFunc("/v1/probes/", s.probe) mux.HandleFunc("/v1/events", s.controlPlaneEvent) // 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) mux.HandleFunc("/api/v1/events", s.controlPlaneEvent) if s.RateLimiter == nil { return mux } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !s.RateLimiter.Allow(requestRateKey(r), s.now()) { writeError(w, http.StatusTooManyRequests, "rate_limited") return } mux.ServeHTTP(w, r) }) } type steamSessionRequest struct { WebAPITicket string `json:"web_api_ticket"` } func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } if s.SteamLogin == nil { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") return } var input steamSessionRequest if !decodeBody(w, r, &input) { return } if input.WebAPITicket == "" || len(input.WebAPITicket) > 4096 { writeError(w, http.StatusBadRequest, "invalid_request") return } now := s.now() identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, now) if err != nil || identity.PlayerID == "" || identity.SteamID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } var session domain.Session var token string if s.SessionIssuer != nil { session, token, err = s.SessionIssuer.Issue(r.Context(), identity.PlayerID, time.Hour, now) } else if s.Sessions != nil { session, token, err = s.Sessions.Issue(identity.PlayerID, time.Hour, now) } else { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") return } if err != nil { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") return } writeJSON(w, http.StatusOK, map[string]any{"player_id": session.PlayerID, "expires_at": session.ExpiresAt, "access_token": session.SessionID + ":" + token}) } func (s *Service) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } type queueCreateRequest struct { TicketID string `json:"ticket_id"` Playlist string `json:"playlist"` ClientBuild string `json:"client_build"` ProtocolVersion int `json:"protocol_version"` } type queueResponse struct { TicketID string `json:"ticket_id"` PlayerID string `json:"player_id"` State string `json:"state"` Revision uint64 `json:"revision"` EnqueuedAt time.Time `json:"enqueued_at"` ExpiresAt time.Time `json:"expires_at"` Playlist string `json:"playlist"` } func (s *Service) queueCreate(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 } if (s.Queue == nil && s.QueueBackend == nil) || (s.QueueBackend == nil && s.Candidate == nil && s.CandidateV2 == nil) { writeError(w, http.StatusServiceUnavailable, "queue_unavailable") return } var input queueCreateRequest if !decodeBody(w, r, &input) { return } if input.TicketID == "" || (input.Playlist != string(domain.Casual) && input.Playlist != string(domain.Ranked)) || input.ClientBuild == "" || len(input.ClientBuild) > 128 || input.ProtocolVersion < 1 { writeError(w, http.StatusBadRequest, "invalid_request") return } key := r.Header.Get("Idempotency-Key") if len(key) < 16 || len(key) > 128 { writeError(w, http.StatusBadRequest, "invalid_idempotency_key") return } now := s.now() spec := domain.QueueSpec{Playlist: domain.Playlist(input.Playlist), ClientBuild: input.ClientBuild, ProtocolVersion: input.ProtocolVersion} if s.QueueBackend != nil { ticket, err := s.QueueBackend.Create(r.Context(), playerID, input.TicketID, key, spec, now) if err != nil { writeDomainError(w, err) return } s.publishTicketEvent(ticket, now) writeJSON(w, http.StatusCreated, toQueueResponse(ticket)) return } var candidate domain.Candidate var err error if s.CandidateV2 != nil { candidate, err = s.CandidateV2(playerID, input.TicketID, spec) } else { candidate, err = s.Candidate(playerID, input.TicketID) // Legacy providers predate queue compatibility metadata. The API has // validated the request; keep the resulting projection self-describing. candidate.Playlist = spec.Playlist candidate.ClientBuild = spec.ClientBuild candidate.ProtocolVersion = spec.ProtocolVersion } if err != nil { writeError(w, http.StatusUnprocessableEntity, "candidate_unavailable") return } if candidate.PlayerID != playerID || candidate.TicketID != input.TicketID || candidate.Playlist != spec.Playlist || candidate.ClientBuild != spec.ClientBuild || candidate.ProtocolVersion != spec.ProtocolVersion { writeError(w, http.StatusUnprocessableEntity, "candidate_mismatch") return } ticket, err := s.Queue.Create(playerID, input.TicketID, key, candidate, now) if err != nil { writeDomainError(w, err) return } s.publishTicketEvent(ticket, now) 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 && r.Method != http.MethodDelete { writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") return } playerID, ok := s.authenticate(w, r) if !ok { return } if s.Queue == nil && s.QueueBackend == nil { writeError(w, http.StatusServiceUnavailable, "queue_unavailable") return } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/queue/"), "/") if r.Method == http.MethodGet { if len(parts) != 1 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found") return } var ticket domain.QueueTicket var err error if s.QueueBackend != nil { ticket, err = s.QueueBackend.Get(r.Context(), playerID, parts[0], s.now()) } else { ticket, err = s.Queue.Get(playerID, parts[0], s.now()) } if err != nil { writeDomainError(w, err) return } writeJSON(w, http.StatusOK, toQueueResponse(ticket)) return } if len(parts) != 2 || parts[0] == "" || (parts[1] != "heartbeat" && parts[1] != "cancel") { writeError(w, http.StatusNotFound, "not_found") return } ticketID, key := parts[0], 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 } now := s.now() var ticket domain.QueueTicket if parts[1] == "heartbeat" { if s.QueueBackend != nil { ticket, err = s.QueueBackend.Heartbeat(r.Context(), playerID, ticketID, key, revision, now) } else { ticket, err = s.Queue.Heartbeat(playerID, ticketID, key, revision, now) } } else { if s.QueueBackend != nil { ticket, err = s.QueueBackend.Cancel(r.Context(), playerID, ticketID, key, revision, now) } else { ticket, err = s.Queue.Cancel(playerID, ticketID, key, revision, now) } } if err != nil { writeDomainError(w, err) return } s.publishTicketEvent(ticket, now) if r.Header.Get("X-Contract-Delete") == "1" { w.WriteHeader(http.StatusNoContent) return } 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 && r.Method != http.MethodGet { 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 r.Method == http.MethodGet { if len(parts) != 1 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found") return } s.proposalMu.Lock() defer s.proposalMu.Unlock() proposal, exists := s.Proposals[parts[0]] if s.ProposalBackend != nil { proposalValue, providerErr := s.ProposalBackend.Get(r.Context(), playerID, parts[0], s.now()) if providerErr != nil { writeError(w, http.StatusNotFound, "not_found") return } proposal = &proposalValue exists = true } if !exists || proposal == nil || !proposal.HasParticipant(playerID) { writeError(w, http.StatusNotFound, "not_found") return } now := s.now() if proposal.Expire(now) { s.publishProposalEvent(*proposal, now) } writeJSON(w, http.StatusOK, toProposalResponse(*proposal)) return } 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 } now := s.now() updated, err := proposal.Respond(playerID, key, parts[1] == "accept", revision, now) if err != nil { writeDomainError(w, err) return } s.publishProposalEvent(updated, now) writeJSON(w, http.StatusOK, toProposalResponse(updated)) } func (s *Service) assignment(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 } parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/assignments/"), "/") if len(parts) != 1 || parts[0] == "" { writeError(w, http.StatusNotFound, "not_found") return } if s.Assignment == nil { writeError(w, http.StatusServiceUnavailable, "assignment_unavailable") return } now := s.now() view, err := s.Assignment(r.Context(), playerID, parts[0], now) if err != nil || view.MatchID != parts[0] || view.PlayerID != playerID { 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) { writeError(w, http.StatusServiceUnavailable, "assignment_unavailable") return } _ = s.PublishControlPlaneEvent(ControlPlaneEvent{Event: "assignment_changed", Revision: 0, ResourceID: view.MatchID, OccurredAt: now, MatchID: view.MatchID, ServerID: view.ServerID, PlayerID: view.PlayerID}) writeJSON(w, http.StatusOK, view) } type rankedProfileResponse struct { Rating float64 `json:"rating"` RD float64 `json:"rd"` Volatility float64 `json:"volatility"` RankedGames int `json:"ranked_games"` Tier string `json:"tier"` Provisional bool `json:"provisional"` 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") return } playerID, ok := s.authenticate(w, r) if !ok { return } profile, exists := s.RankedProfiles[playerID] if !exists { writeError(w, http.StatusNotFound, "not_found") return } tier, err := domain.RankedTier(profile, s.TierPolicy) if err != nil { writeError(w, http.StatusServiceUnavailable, "ranked_profile_unavailable") return } writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: profile.LastSeasonID}) } type probeRequest struct { OpaqueLocation []byte `json:"opaque_location"` Nonce []byte `json:"nonce"` } func (s *Service) probe(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 } region := strings.TrimPrefix(r.URL.Path, "/v1/probes/") if (region != "EU" && region != "NA") || strings.Contains(region, "/") { writeError(w, http.StatusNotFound, "not_found") return } if s.Probe == nil { writeError(w, http.StatusServiceUnavailable, "probe_unavailable") return } var input probeRequest if !decodeBody(w, r, &input) { return } receivedAt := s.now() evidence, expectedNonce, err := s.Probe(playerID, region, input.OpaqueLocation, input.Nonce, receivedAt) if err != nil { writeError(w, http.StatusUnprocessableEntity, "probe_unavailable") return } if evidence.Region != region || domain.ValidateProbe(evidence, expectedNonce, receivedAt) != nil { writeError(w, http.StatusUnprocessableEntity, "invalid_probe") return } writeJSON(w, http.StatusAccepted, map[string]any{"region": region, "server_rtt_ms": evidence.ServerRTT.Milliseconds(), "status": "accepted"}) } func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { if s.Sessions == nil && s.SessionBackend == nil { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") return "", false } parts := strings.Fields(r.Header.Get("Authorization")) if len(parts) != 2 || parts[0] != "Bearer" { writeError(w, http.StatusUnauthorized, "unauthorized") return "", false } separator := strings.IndexByte(parts[1], ':') if separator <= 0 || separator == len(parts[1])-1 { writeError(w, http.StatusUnauthorized, "unauthorized") return "", false } var session domain.Session var err error if s.SessionBackend != nil { session, err = s.SessionBackend.Authenticate(r.Context(), parts[1][:separator], parts[1][separator+1:], s.now()) } else { session, err = s.Sessions.Authenticate(parts[1][:separator], parts[1][separator+1:], s.now()) } if err != nil { writeError(w, http.StatusUnauthorized, "unauthorized") return "", false } return session.PlayerID, true } func (s *Service) now() time.Time { if s.Now != nil { return s.Now() } return time.Now().UTC() } func decodeBody(w http.ResponseWriter, r *http.Request, target any) bool { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { writeError(w, http.StatusBadRequest, "invalid_request") return false } var extra any if err := decoder.Decode(&extra); err != io.EOF { writeError(w, http.StatusBadRequest, "invalid_request") return false } return true } func toQueueResponse(ticket domain.QueueTicket) queueResponse { return queueResponse{TicketID: ticket.TicketID, PlayerID: ticket.PlayerID, Playlist: string(ticket.Playlist), 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): writeError(w, http.StatusConflict, "conflict") case errors.Is(err, domain.ErrTicketExpired): writeError(w, http.StatusGone, "expired") case errors.Is(err, domain.ErrNotTicketOwner): writeError(w, http.StatusForbidden, "forbidden") case errors.Is(err, domain.ErrTicketNotFound): writeError(w, http.StatusNotFound, "not_found") default: writeError(w, http.StatusUnprocessableEntity, "invalid_request") } } func writeError(w http.ResponseWriter, status int, code string) { writeJSON(w, status, map[string]string{"error": code}) } func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(value) }