mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 22:03:42 +00:00
4eaa3304c3
Wire the same Service.Log hook added for the server register/result routes into queue create/heartbeat/cancel and proposal accept/decline: log the resulting state on success (queue_create, queue_heartbeat, queue_cancel, proposal_response) or 'rejected' on a domain error, using only the ticket/proposal ID and outcome -- never the domain error text itself, which isn't documented as credential-free. Read-only routes (queue GET, proposal GET, assignment fetch) and the early availability/not-found rejections that return before reaching the domain call are deliberately not logged in this pass. Covered by a new end-to-end test driving real create/heartbeat/cancel and an accept followed by a stale-revision accept (fenced for real by the domain layer behind proposalBackendSpy, unlike the dumb queue spy), asserting the exact sequence of events logged.
961 lines
34 KiB
Go
961 lines
34 KiB
Go
// 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"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cosmic-clash/cosmic-clash/server/domain"
|
|
"github.com/cosmic-clash/cosmic-clash/server/observability"
|
|
)
|
|
|
|
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 ProbeRecorder interface {
|
|
RecordProbe(context.Context, string, string, time.Duration, time.Time) error
|
|
}
|
|
type WorkloadVerifier func(string, time.Time) (domain.WorkloadBinding, error)
|
|
type ResultSubmitter interface {
|
|
SubmitResult(context.Context, string, domain.MatchResult, domain.WorkloadBinding, []byte, time.Time) error
|
|
}
|
|
type ServerRegistrar interface {
|
|
RegisterServer(context.Context, domain.WorkloadBinding, int, bool, string, time.Time) 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)
|
|
}
|
|
|
|
// CandidateIndex is a transient projection of durable queue ownership. Index
|
|
// failures must never change the result of an already successful mutation.
|
|
type CandidateIndex interface {
|
|
Upsert(context.Context, domain.Candidate) error
|
|
Remove(context.Context, string) 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 ProposalMutationBackend interface {
|
|
Respond(context.Context, string, string, string, bool, uint64, time.Time) (domain.Proposal, error)
|
|
}
|
|
type ProposalPromoter interface {
|
|
Promote(context.Context, domain.Proposal, time.Time) error
|
|
}
|
|
type ProposalPromoterFunc func(context.Context, domain.Proposal, time.Time) error
|
|
|
|
func (f ProposalPromoterFunc) Promote(ctx context.Context, proposal domain.Proposal, now time.Time) error {
|
|
return f(ctx, proposal, now)
|
|
}
|
|
|
|
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"`
|
|
Endpoint string `json:"endpoint"`
|
|
// Revision is routing metadata for the event stream, not part of the v1
|
|
// assignment response. Keeping it alongside the durable view prevents the
|
|
// REST recovery boundary from emitting a synthetic revision zero.
|
|
Revision uint64 `json:"-"`
|
|
}
|
|
|
|
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
|
|
CandidateIndex CandidateIndex
|
|
Probe ProbeProvider
|
|
ProbeRecorder ProbeRecorder
|
|
WorkloadVerify WorkloadVerifier
|
|
ResultSubmitter ResultSubmitter
|
|
ServerRegistrar ServerRegistrar
|
|
Assignment AssignmentProvider
|
|
Now func() time.Time
|
|
Proposals map[string]*domain.Proposal
|
|
ProposalBackend ProposalBackend
|
|
ProposalPromoter ProposalPromoter
|
|
RankedProfiles map[string]domain.RankedProfile
|
|
TierPolicy domain.TierPolicy
|
|
RateLimiter *RateLimiter
|
|
// Log receives a credential-safe structured event for lifecycle-relevant
|
|
// mutations (currently: server registration and result submission). Nil
|
|
// is a valid, silent no-op -- every call site must stay optional so
|
|
// existing Service literals that don't set it keep working unchanged.
|
|
Log func(observability.Event)
|
|
proposalMu sync.Mutex
|
|
eventsMu sync.Mutex
|
|
events *eventHub
|
|
}
|
|
|
|
// logEvent is a nil-safe wrapper so call sites never need their own guard.
|
|
func (s *Service) logEvent(event observability.Event) {
|
|
if s.Log != nil {
|
|
s.Log(event)
|
|
}
|
|
}
|
|
|
|
// logQueueOutcome logs a queue-ticket mutation's result: the ticket's
|
|
// resulting state on success, or "rejected" on a domain error. It never logs
|
|
// the error text itself -- domain errors here are not documented as
|
|
// credential-free, and the stage name already tells an operator what to look
|
|
// up (the ticket ID, still recorded either way).
|
|
func (s *Service) logQueueOutcome(event, ticketID string, ticket domain.QueueTicket, err error, now time.Time) {
|
|
if err != nil {
|
|
s.logEvent(observability.Event{Event: event, QueueID: ticketID, Stage: "rejected", OccurredAt: now})
|
|
return
|
|
}
|
|
s.logEvent(observability.Event{Event: event, QueueID: ticket.TicketID, Stage: strings.ToLower(string(ticket.State)), OccurredAt: now})
|
|
}
|
|
|
|
// logProposalOutcome mirrors logQueueOutcome for proposal accept/decline.
|
|
func (s *Service) logProposalOutcome(proposalID string, proposal domain.Proposal, err error, now time.Time) {
|
|
if err != nil {
|
|
s.logEvent(observability.Event{Event: "proposal_response", ProposalID: proposalID, Stage: "rejected", OccurredAt: now})
|
|
return
|
|
}
|
|
s.logEvent(observability.Event{Event: "proposal_response", ProposalID: proposal.ProposalID, Stage: strings.ToLower(string(proposal.State)), OccurredAt: now})
|
|
}
|
|
|
|
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)
|
|
mux.HandleFunc("/v1/servers/", s.serverMutation)
|
|
// 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)
|
|
mux.HandleFunc("/api/v1/servers/", s.contractServerMutation)
|
|
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 {
|
|
s.logQueueOutcome("queue_create", input.TicketID, ticket, err, now)
|
|
writeDomainError(w, err)
|
|
return
|
|
}
|
|
s.logQueueOutcome("queue_create", input.TicketID, ticket, nil, now)
|
|
s.projectCandidate(r.Context(), ticket)
|
|
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 {
|
|
s.logQueueOutcome("queue_create", input.TicketID, ticket, err, now)
|
|
writeDomainError(w, err)
|
|
return
|
|
}
|
|
s.logQueueOutcome("queue_create", input.TicketID, ticket, nil, now)
|
|
s.projectCandidate(r.Context(), ticket)
|
|
s.publishTicketEvent(ticket, now)
|
|
writeJSON(w, http.StatusCreated, toQueueResponse(ticket))
|
|
}
|
|
|
|
func (s *Service) projectCandidate(ctx context.Context, ticket domain.QueueTicket) {
|
|
if s.CandidateIndex != nil {
|
|
_ = s.CandidateIndex.Upsert(ctx, ticket.Candidate)
|
|
}
|
|
}
|
|
|
|
func (s *Service) removeCandidate(ctx context.Context, ticketID string) {
|
|
if s.CandidateIndex != nil {
|
|
_ = s.CandidateIndex.Remove(ctx, ticketID)
|
|
}
|
|
}
|
|
|
|
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) contractServerMutation(w http.ResponseWriter, r *http.Request) {
|
|
// Unlike contractAssignment, the documented shape here is two segments
|
|
// (/servers/{serverId}/result, /servers/{serverId}/register) — rejecting
|
|
// any "/" would 404 every real call. Delegate shape validation to
|
|
// serverMutation, which already enforces exactly {id}/{result|register}.
|
|
path := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/")
|
|
if path == "" {
|
|
writeError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
clone := r.Clone(r.Context())
|
|
clone.URL.Path = "/v1/servers/" + path
|
|
s.serverMutation(w, clone)
|
|
}
|
|
|
|
type resultRequest struct {
|
|
MatchID string `json:"match_id"`
|
|
ResultNonce string `json:"result_nonce"`
|
|
Score struct {
|
|
Team0 int `json:"team_0"`
|
|
Team1 int `json:"team_1"`
|
|
} `json:"score"`
|
|
IntegrityState domain.IntegrityState `json:"integrity_state"`
|
|
}
|
|
|
|
type serverRegistrationRequest struct {
|
|
MatchID string `json:"match_id"`
|
|
ProtocolVersion int `json:"protocol_version"`
|
|
ImageDigest string `json:"image_digest"`
|
|
AssignmentReady bool `json:"assignment_ready"`
|
|
}
|
|
|
|
func (s *Service) serverMutation(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
|
return
|
|
}
|
|
parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/v1/servers/"), "/")
|
|
if len(parts) != 2 || parts[0] == "" || (parts[1] != "result" && parts[1] != "register") {
|
|
writeError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
if s.WorkloadVerify == nil || (parts[1] == "result" && s.ResultSubmitter == nil) || (parts[1] == "register" && s.ServerRegistrar == nil) {
|
|
writeError(w, http.StatusServiceUnavailable, "server_unavailable")
|
|
return
|
|
}
|
|
key := r.Header.Get("Idempotency-Key")
|
|
if len(key) < 16 || len(key) > 128 {
|
|
writeError(w, http.StatusBadRequest, "invalid_idempotency_key")
|
|
return
|
|
}
|
|
partsAuth := strings.Fields(r.Header.Get("Authorization"))
|
|
if len(partsAuth) != 2 || partsAuth[0] != "Bearer" || partsAuth[1] == "" {
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
now := s.now()
|
|
binding, err := s.WorkloadVerify(partsAuth[1], now)
|
|
if err != nil || binding.ServerID != parts[0] {
|
|
s.logEvent(observability.Event{Event: "server_" + parts[1], ServerID: parts[0], Stage: "unauthorized", OccurredAt: now})
|
|
writeError(w, http.StatusUnauthorized, "unauthorized")
|
|
return
|
|
}
|
|
if parts[1] == "register" {
|
|
var input serverRegistrationRequest
|
|
if !decodeBody(w, r, &input) {
|
|
return
|
|
}
|
|
if input.MatchID == "" || input.MatchID != binding.MatchID || input.ProtocolVersion < 1 || !validImageDigest(input.ImageDigest) {
|
|
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
|
return
|
|
}
|
|
if err := s.ServerRegistrar.RegisterServer(r.Context(), binding, input.ProtocolVersion, input.AssignmentReady, key, now); err != nil {
|
|
stage := "invalid"
|
|
if errors.Is(err, domain.ErrConflict) {
|
|
stage = "conflict"
|
|
writeError(w, http.StatusConflict, "conflict")
|
|
} else {
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
|
}
|
|
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
|
|
return
|
|
}
|
|
readyStage := "process_ready"
|
|
if input.AssignmentReady {
|
|
readyStage = "assignment_ready"
|
|
}
|
|
s.logEvent(observability.Event{Event: "server_register", MatchID: binding.MatchID, ServerID: parts[0], Stage: readyStage, OccurredAt: now, Fields: map[string]any{"protocol_version": input.ProtocolVersion}})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
var input resultRequest
|
|
if !decodeBody(w, r, &input) {
|
|
return
|
|
}
|
|
if input.MatchID == "" || binding.MatchID != input.MatchID || len(input.ResultNonce) < 16 || len(input.ResultNonce) > 128 || input.Score.Team0 < 0 || input.Score.Team1 < 0 || (input.IntegrityState != domain.IntegrityCertified && input.IntegrityState != domain.IntegritySuppressed && input.IntegrityState != domain.IntegrityReview) {
|
|
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: "rejected", OccurredAt: now})
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
|
return
|
|
}
|
|
result := domain.MatchResult{MatchID: input.MatchID, ServerID: parts[0], ResultNonce: input.ResultNonce, Team0Score: input.Score.Team0, Team1Score: input.Score.Team1, IntegrityState: input.IntegrityState}
|
|
payload, err := json.Marshal(input)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request")
|
|
return
|
|
}
|
|
if err := s.ResultSubmitter.SubmitResult(r.Context(), key, result, binding, payload, now); err != nil {
|
|
stage := "invalid"
|
|
if errors.Is(err, domain.ErrResultConflict) || strings.Contains(err.Error(), "conflict") {
|
|
stage = "conflict"
|
|
writeError(w, http.StatusConflict, "conflict")
|
|
} else {
|
|
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
|
|
}
|
|
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: stage, OccurredAt: now})
|
|
return
|
|
}
|
|
s.logEvent(observability.Event{Event: "server_result", MatchID: binding.MatchID, ServerID: parts[0], Stage: "accepted", OccurredAt: now, Fields: map[string]any{"integrity_state": string(input.IntegrityState), "team_0": input.Score.Team0, "team_1": input.Score.Team1}})
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
func validImageDigest(value string) bool {
|
|
if len(value) != len("sha256:")+64 || !strings.HasPrefix(value, "sha256:") {
|
|
return false
|
|
}
|
|
for _, ch := range value[len("sha256:"):] {
|
|
if !(ch >= '0' && ch <= '9') && !(ch >= 'a' && ch <= 'f') {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
eventName := "queue_heartbeat"
|
|
if parts[1] == "cancel" {
|
|
eventName = "queue_cancel"
|
|
}
|
|
if err != nil {
|
|
s.logQueueOutcome(eventName, ticketID, ticket, err, now)
|
|
writeDomainError(w, err)
|
|
return
|
|
}
|
|
s.logQueueOutcome(eventName, ticketID, ticket, nil, now)
|
|
if ticket.State == domain.Cancelled {
|
|
s.removeCandidate(r.Context(), ticket.TicketID)
|
|
} else {
|
|
s.projectCandidate(r.Context(), ticket)
|
|
}
|
|
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
|
|
}
|
|
now := s.now()
|
|
var updated domain.Proposal
|
|
if s.ProposalBackend != nil {
|
|
mutator, supportsMutation := s.ProposalBackend.(ProposalMutationBackend)
|
|
if !supportsMutation {
|
|
writeError(w, http.StatusServiceUnavailable, "proposal_unavailable")
|
|
return
|
|
}
|
|
updated, err = mutator.Respond(r.Context(), playerID, parts[0], key, parts[1] == "accept", revision, now)
|
|
} else {
|
|
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, now)
|
|
}
|
|
if err != nil {
|
|
s.logProposalOutcome(parts[0], updated, err, now)
|
|
writeDomainError(w, err)
|
|
return
|
|
}
|
|
s.logProposalOutcome(parts[0], updated, nil, now)
|
|
if updated.State == domain.Accepted && s.ProposalPromoter != nil {
|
|
if err := s.ProposalPromoter.Promote(r.Context(), updated, now); err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "match_promotion_unavailable")
|
|
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 == "" || !validAssignmentEndpoint(view.Endpoint) || view.ExpiresAt.IsZero() || !now.Before(view.ExpiresAt) {
|
|
writeError(w, http.StatusServiceUnavailable, "assignment_unavailable")
|
|
return
|
|
}
|
|
_ = s.PublishControlPlaneEvent(assignmentChangedEvent(view, now))
|
|
writeJSON(w, http.StatusOK, view)
|
|
}
|
|
|
|
func validAssignmentEndpoint(endpoint string) bool {
|
|
if endpoint == "" || strings.ContainsAny(endpoint, "/?#") {
|
|
return false
|
|
}
|
|
host, portText, err := net.SplitHostPort(endpoint)
|
|
if err != nil || host == "" {
|
|
return false
|
|
}
|
|
port, err := strconv.Atoi(portText)
|
|
return err == nil && port >= 1 && port <= 65535
|
|
}
|
|
|
|
func assignmentChangedEvent(view AssignmentView, now time.Time) ControlPlaneEvent {
|
|
return ControlPlaneEvent{Event: "assignment_changed", Revision: view.Revision, ResourceID: view.MatchID, OccurredAt: now, MatchID: view.MatchID, ServerID: view.ServerID, PlayerID: view.PlayerID}
|
|
}
|
|
|
|
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
|
|
}
|
|
if s.ProbeRecorder != nil {
|
|
if err := s.ProbeRecorder.RecordProbe(r.Context(), playerID, region, evidence.ServerRTT, receivedAt); err != nil {
|
|
writeError(w, http.StatusServiceUnavailable, "probe_persistence_failed")
|
|
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)
|
|
}
|