feat: add workload-authenticated result API

This commit is contained in:
Josh Creek
2026-09-01 10:01:04 +01:00
parent a70a0ebc74
commit eebab1bc19
6 changed files with 150 additions and 3 deletions
+84
View File
@@ -29,6 +29,10 @@ type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, r
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 QueueBackend interface {
Create(context.Context, string, string, string, domain.QueueSpec, time.Time) (domain.QueueTicket, error)
@@ -90,6 +94,8 @@ type Service struct {
CandidateIndex CandidateIndex
Probe ProbeProvider
ProbeRecorder ProbeRecorder
WorkloadVerify WorkloadVerifier
ResultSubmitter ResultSubmitter
Assignment AssignmentProvider
Now func() time.Time
Proposals map[string]*domain.Proposal
@@ -113,6 +119,7 @@ func (s *Service) Handler() http.Handler {
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)
@@ -122,6 +129,7 @@ func (s *Service) Handler() http.Handler {
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
}
@@ -351,6 +359,82 @@ func (s *Service) contractAssignment(w http.ResponseWriter, r *http.Request) {
s.assignment(w, clone)
}
func (s *Service) contractServerMutation(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/")
if path == "" || strings.Contains(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"`
}
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" {
writeError(w, http.StatusNotFound, "not_found")
return
}
if s.WorkloadVerify == nil || s.ResultSubmitter == nil {
writeError(w, http.StatusServiceUnavailable, "result_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] {
writeError(w, http.StatusUnauthorized, "unauthorized")
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) {
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 {
if errors.Is(err, domain.ErrResultConflict) || strings.Contains(err.Error(), "conflict") {
writeError(w, http.StatusConflict, "conflict")
} else {
writeError(w, http.StatusUnprocessableEntity, "invalid_request")
}
return
}
w.WriteHeader(http.StatusAccepted)
}
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")
+47
View File
@@ -34,6 +34,19 @@ type probeRecorderSpy struct {
}
}
type resultSubmitterSpy struct {
calls int
err error
key string
result domain.MatchResult
}
func (r *resultSubmitterSpy) SubmitResult(_ context.Context, key string, result domain.MatchResult, _ domain.WorkloadBinding, _ []byte, _ time.Time) error {
r.calls++
r.key, r.result = key, result
return r.err
}
func (p *probeRecorderSpy) RecordProbe(_ context.Context, player, region string, rtt time.Duration, _ time.Time) error {
p.calls++
p.last.player, p.last.region, p.last.rtt = player, region, rtt
@@ -916,6 +929,40 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) {
}
}
func TestServerResultAPIRequiresBoundWorkloadAndDelegatesDurableSubmission(t *testing.T) {
now := time.Unix(1000, 0).UTC()
binding := domain.WorkloadBinding{MatchID: "match-1", ServerID: "server-1"}
submitter := &resultSubmitterSpy{}
service := &Service{Now: func() time.Time { return now }, WorkloadVerify: func(token string, at time.Time) (domain.WorkloadBinding, error) {
if token != "workload-token" || !at.Equal(now) {
t.Fatalf("verifier input=%q %v", token, at)
}
return binding, nil
}, ResultSubmitter: submitter}
server := httptest.NewServer(service.Handler())
defer server.Close()
body := `{"match_id":"match-1","result_nonce":"nonce-1234567890","score":{"team_0":3,"team_1":2},"integrity_state":"CERTIFIED"}`
req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-1/result", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer workload-token")
req.Header.Set("Idempotency-Key", "result-key-123456")
response, err := http.DefaultClient.Do(req)
if err != nil || response.StatusCode != http.StatusAccepted {
t.Fatalf("status=%v err=%v", response.StatusCode, err)
}
response.Body.Close()
if submitter.calls != 1 || submitter.key != "result-key-123456" || submitter.result.Team0Score != 3 {
t.Fatalf("submission=%+v calls=%d", submitter, submitter.calls)
}
req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/servers/server-2/result", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer workload-token")
req.Header.Set("Idempotency-Key", "result-key-123456")
response, err = http.DefaultClient.Do(req)
if err != nil || response.StatusCode != http.StatusUnauthorized {
t.Fatalf("wrong server status=%v err=%v", response.StatusCode, err)
}
response.Body.Close()
}
func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) {
now := time.Unix(1000, 0).UTC()
sessions := domain.NewSessionStore()