feat: add authenticated proposal API

This commit is contained in:
Josh Creek
2026-08-31 21:17:15 +01:00
parent 77c01dc531
commit 8d1b407bb0
3 changed files with 101 additions and 5 deletions
+60 -4
View File
@@ -10,6 +10,7 @@ import (
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/cosmic-clash/cosmic-clash/server/domain"
@@ -20,10 +21,12 @@ const maxBodyBytes = 8 << 10
type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error)
type Service struct {
Sessions *domain.SessionStore
Queue *domain.Queue
Candidate CandidateProvider
Now func() time.Time
Sessions *domain.SessionStore
Queue *domain.Queue
Candidate CandidateProvider
Now func() time.Time
Proposals map[string]*domain.Proposal
proposalMu sync.Mutex
}
func (s *Service) Handler() http.Handler {
@@ -31,6 +34,7 @@ func (s *Service) Handler() http.Handler {
mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/v1/queue", s.queueCreate)
mux.HandleFunc("/v1/queue/", s.queueMutation)
mux.HandleFunc("/v1/proposals/", s.proposalMutation)
return mux
}
@@ -132,6 +136,54 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
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))
}
func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.Sessions == nil {
writeError(w, http.StatusServiceUnavailable, "auth_unavailable")
@@ -182,6 +234,10 @@ 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):