// 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 ( "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 ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) type Service struct { Sessions *domain.SessionStore Queue *domain.Queue Candidate CandidateProvider 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/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 } 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"` } 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"` } 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.Candidate == nil { writeError(w, http.StatusServiceUnavailable, "queue_unavailable") return } var input queueCreateRequest if !decodeBody(w, r, &input) { return } if input.TicketID == "" { 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() candidate, err := s.Candidate(playerID, input.TicketID) if err != nil { writeError(w, http.StatusUnprocessableEntity, "candidate_unavailable") 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 { 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 } 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" { ticket, err = s.Queue.Heartbeat(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 { 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)) } 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 { 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 } 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, 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) }