package web import ( "encoding/json" "errors" "io" "log/slog" "net/http" "pete/internal/storage" ) // The euro/chip wire. // // gogobee owns the euros and has no inbound API, so it is the only initiator: // it polls this endpoint for border crossings, moves the money on its side, and // pushes the verdict back through the durable queue it already uses for // adventure facts. Pete never calls gogobee. That direction of travel is a // standing rule of the seam, not an implementation detail — see roster.go. // // All three endpoints are bearer-authed with the same ingest token as the // adventure seam, and all three are idempotent, because the thing on the other // end of them is a retrying queue and the thing they move is money. // // The storage layer under this (internal/storage/games.go) is where the actual // invariant lives: chips exist only once gogobee confirms it took the euros. // These handlers are transport, and deliberately nothing more. // escrowGUID is the body of the two POSTs that name a row. type escrowGUID struct { GUID string `json:"guid"` } // escrowVerdict is gogobee's answer: did the money move, and what is the // player's euro balance now. type escrowVerdict struct { GUID string `json:"guid"` OK bool `json:"ok"` Reason string `json:"reason,omitempty"` BalanceAfter float64 `json:"balance_after"` } // handleEscrowPending is gogobee's poll: every crossing waiting to be moved. // // It includes rows gogobee claimed but never reported on — see // storage.PendingEscrow. Re-offering those is the whole reason the guid is an // idempotency key: if gogobee already moved the euros, the retry is a no-op // that reports the same answer, and if it died before moving them, the money // gets moved now instead of being stranded. func (s *Server) handleEscrowPending(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } rows, err := storage.PendingEscrow(escrowPollLimit) if err != nil { slog.Error("games: pending escrow", "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if rows == nil { rows = []storage.Escrow{} // an empty poll is [], never null } writeJSON(w, rows) } // escrowPollLimit caps one poll. gogobee polls every few seconds, so a backlog // drains in a handful of ticks rather than arriving as one enormous body. const escrowPollLimit = 50 // handleEscrowClaim marks a row as taken. It is not a lock — a row already // claimed can be claimed again, which is how a stale re-offer works — but a row // that has already reached a verdict cannot be, which is what stops a settled // cash-out being paid twice. // // The claimed row goes back in the response, so gogobee moves the money against // the amount and the user *Pete* holds rather than the ones it read a poll ago. func (s *Server) handleEscrowClaim(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var req escrowGUID if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&req); err != nil || req.GUID == "" { http.Error(w, "guid is required", http.StatusBadRequest) return } e, err := storage.ClaimEscrow(req.GUID) if errors.Is(err, storage.ErrNoSuchEscrow) { http.Error(w, "no such escrow", http.StatusNotFound) return } if err != nil { slog.Error("games: claim escrow", "guid", req.GUID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, e) } // handleEscrowSettled applies gogobee's verdict. This is the only way chips are // ever created, and it runs exactly once per guid no matter how many times the // push is redelivered. // // An unknown guid is a 400 rather than a shrug: gogobee has, by this point, // already moved real euros for a row Pete has no record of. Under the contract // the adventure seam established, a 400 makes gogobee's sender park the row // instead of retrying it forever — which is right, because no amount of retrying // invents the missing row. It leaves the payload sitting in gogobee's queue, // where a human can find it. func (s *Server) handleEscrowSettled(w http.ResponseWriter, r *http.Request) { if !s.bearerOK(r) { http.Error(w, "unauthorized", http.StatusUnauthorized) return } var v escrowVerdict if err := json.NewDecoder(io.LimitReader(r.Body, 1<<14)).Decode(&v); err != nil { http.Error(w, "bad json", http.StatusBadRequest) return } if v.GUID == "" { http.Error(w, "guid is required", http.StatusBadRequest) return } e, err := storage.SettleEscrow(v.GUID, v.OK, v.Reason, v.BalanceAfter) if errors.Is(err, storage.ErrNoSuchEscrow) { slog.Error("games: verdict for an escrow row we have never heard of — "+ "gogobee has moved euros against it and Pete cannot honour them", "guid", v.GUID, "ok", v.OK) http.Error(w, "no such escrow", http.StatusBadRequest) return } if err != nil { slog.Error("games: settle escrow", "guid", v.GUID, "err", err) http.Error(w, "internal error", http.StatusInternalServerError) return } slog.Info("games: escrow settled", "guid", e.GUID, "user", e.MatrixUser, "kind", e.Kind, "amount", e.Amount, "state", e.State, "reason", e.Reason) writeJSON(w, e) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(v); err != nil { slog.Error("games: write response", "err", err) } }