// 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 ( "context" "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 Service struct { Sessions *domain.SessionStore SessionBackend SessionBackend SessionIssuer SessionIssuer SteamLogin SteamLoginProvider Queue *domain.Queue Candidate CandidateProvider CandidateV2 CandidateProviderV2 QueueBackend QueueBackend Probe ProbeProvider Now func() time.Time Proposals map[string]*domain.Proposal RankedProfiles map[string]domain.RankedProfile TierPolicy domain.TierPolicy proposalMu sync.Mutex } 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/profile/ranked", s.rankedProfile) mux.HandleFunc("/v1/probes/", s.probe) return mux } 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 } 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 } writeJSON(w, http.StatusCreated, toQueueResponse(ticket)) } func (s *Service) queueMutation(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 } 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 } 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 !exists || proposal == nil || !proposal.HasParticipant(playerID) { writeError(w, http.StatusNotFound, "not_found") return } proposal.Expire(s.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 } 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)) } 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) 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) }