mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: add authenticated queue recovery
This commit is contained in:
@@ -69,6 +69,8 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md).
|
||||
|
||||
- [ ] **IN PROGRESS:** Add one PostgreSQL-owned queue ticket/player with 10 s
|
||||
heartbeat, 30 s expiry, Redis candidate cache and restart/failover repair.
|
||||
Authenticated owner-only recovery reads now return terminal expiry correctly;
|
||||
PostgreSQL/Redis wiring remains.
|
||||
- [ ] **IN PROGRESS:** Validate opaque Steam ping locations and nonce-bound probes server-side;
|
||||
require <=100 ms, enforce discrepancy quarantine and the locked widening/
|
||||
region/team tie-break rules.
|
||||
|
||||
+2
-2
@@ -1191,7 +1191,7 @@ the local/CI/community transport, not a silent production fallback.
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, server-owned candidate resolution, bounded/strict JSON input, cache loss and atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain |
|
||||
| 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, expired recovery as a terminal error, server-owned candidate resolution, bounded/strict JSON input and cache loss/atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain |
|
||||
| 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; Steam coordinator and regional probe adapters remain |
|
||||
| 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures remain |
|
||||
| 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API now exposes revisioned accept/decline mutations | `server/domain/proposal.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain |
|
||||
@@ -1227,7 +1227,7 @@ the local/CI/community transport, not a silent production fallback.
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 8.39 `[D:8.3,8.14,8.17]` | Queue UI: playlist/quality, elapsed and estimated wait, proposal countdown, allocation/connect state, cancel and latency/capacity explanations | Every backend state and terminal failure has a non-stuck visible state; cancel/decline is acknowledged authoritatively |
|
||||
| 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision | `server/domain/sync.go` covers gap, snapshot, replay and same-revision conflict behavior; authenticated WebSocket/REST transport, client restart persistence and duplicate-ticket integration remain |
|
||||
| 8.40 `[D:8.3,8.14]` | **IN PROGRESS.** Pure Go revisioned replica reducer rejects gaps for REST resync, makes duplicate/out-of-order events idempotent, and resumes from the authoritative snapshot revision; authenticated queue-ticket recovery now has an owner-checked REST read | `server/domain/sync.go` and `server/api/service.go` cover gap, snapshot, replay, same-revision conflict, owner-only ticket recovery and expired-ticket terminal handling; authenticated WebSocket transport, client restart persistence and duplicate-ticket integration remain |
|
||||
| 8.41 `[D:7.8,8.9,8.31,8.40]` | After assignment-ready, install SDR relay ticket before connect and send match-scoped join authorisation in `hello`; retain ENet assignments locally | Production connects/reconnects/fences old generation through SDR, never before assignment-ready; allocated/direct ENet and community flows remain compatible |
|
||||
| 8.42 `[D:8.22,8.23,8.24,8.40]` | Backend-authoritative provisional/rank/tier/delta, abandon status and season countdown UI | Client performs no rating math and displays the committed revision after reconnect |
|
||||
| 8.43 `[D:8.39,8.40,8.41]` | Recovery paths for decline, expiry, startup failure, version mismatch, auth expiry, regional outage and failed reconnect | Automated UI/state tests prove every case returns to a usable queue/menu or resumes the match without a duplicate action |
|
||||
|
||||
+14
-1
@@ -98,7 +98,7 @@ func (s *Service) queueCreate(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
if r.Method != http.MethodPost && r.Method != http.MethodGet {
|
||||
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed")
|
||||
return
|
||||
}
|
||||
@@ -111,6 +111,19 @@ func (s *Service) queueMutation(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
@@ -112,6 +112,53 @@ func TestQueueAPIRejectsUnauthenticatedUnknownAndOversizedInput(t *testing.T) {
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
func TestQueueRecoveryAPIIsAuthenticatedOwnerOnlyAndExpiresStaleTickets(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
sessions := domain.NewSessionStore()
|
||||
ownerSession, ownerToken, err := sessions.Issue("player-1", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
otherSession, otherToken, err := sessions.Issue("player-2", time.Hour, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queue := domain.NewQueue()
|
||||
service := &Service{Sessions: sessions, Queue: queue, Now: func() time.Time { return now }, Candidate: func(playerID, ticketID string) (domain.Candidate, error) {
|
||||
return domain.Candidate{PlayerID: playerID, TicketID: ticketID, EnqueuedAt: now}, nil
|
||||
}}
|
||||
server := httptest.NewServer(service.Handler())
|
||||
defer server.Close()
|
||||
create, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/queue", strings.NewReader(`{"ticket_id":"ticket-recovery-123456"}`))
|
||||
create.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken)
|
||||
create.Header.Set("Idempotency-Key", "queue-create-recovery-123456")
|
||||
response, err := http.DefaultClient.Do(create)
|
||||
if err != nil || response.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("create status=%v err=%v", response.StatusCode, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
get, _ := http.NewRequest(http.MethodGet, server.URL+"/v1/queue/ticket-recovery-123456", nil)
|
||||
get.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken)
|
||||
response, err = http.DefaultClient.Do(get)
|
||||
if err != nil || response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("owner recovery status=%v err=%v", response.StatusCode, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
get.Header.Set("Authorization", "Bearer "+otherSession.SessionID+":"+otherToken)
|
||||
response, err = http.DefaultClient.Do(get)
|
||||
if err != nil || response.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("cross-player recovery status=%v err=%v", response.StatusCode, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
service.Now = func() time.Time { return now.Add(domain.QueueExpiryWindow) }
|
||||
get.Header.Set("Authorization", "Bearer "+ownerSession.SessionID+":"+ownerToken)
|
||||
response, err = http.DefaultClient.Do(get)
|
||||
if err != nil || response.StatusCode != http.StatusGone {
|
||||
t.Fatalf("expired recovery status=%v err=%v", response.StatusCode, err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
}
|
||||
|
||||
func TestAuthenticatedProposalAPIUsesRevisionAndIdempotencyPolicy(t *testing.T) {
|
||||
now := time.Unix(1000, 0).UTC()
|
||||
sessions := domain.NewSessionStore()
|
||||
|
||||
@@ -139,6 +139,23 @@ func (q *Queue) Cancel(playerID, ticketID, idempotencyKey string, expectedRevisi
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
// Get is the recovery read used after a client restart or missed event. It
|
||||
// never returns another player's ticket and expires stale queue presence before
|
||||
// deciding what the caller may resume.
|
||||
func (q *Queue) Get(playerID, ticketID string, now time.Time) (QueueTicket, error) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
q.expireLocked(now)
|
||||
ticket, err := q.ownedTicket(playerID, ticketID)
|
||||
if err != nil {
|
||||
return QueueTicket{}, err
|
||||
}
|
||||
if ticket.State == Expired {
|
||||
return QueueTicket{}, ErrTicketExpired
|
||||
}
|
||||
return ticket, nil
|
||||
}
|
||||
|
||||
func (q *Queue) Expire(now time.Time) []QueueTicket {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
Reference in New Issue
Block a user