Three bearer-authed endpoints and gogobee can work the border: poll what's waiting, claim a row, report what happened to the money. The storage layer underneath was already done; this is the transport, and deliberately nothing more. All three are idempotent, because the thing on the other end of them is a retrying queue and the thing they move is money. A verdict delivered three times creates chips once. A rejected buy-in moves nothing and clears the pending amount so it stops eating the table cap. A cash-out gogobee couldn't pay gives the chips back rather than vanishing them from both sides. A verdict for a row Pete has never heard of is a 400, not a shrug: gogobee has by then moved real euros against it, and no amount of retrying invents the missing row. Under the contract the adventure seam set, a 400 parks it in gogobee's queue where a human can find it.
152 lines
5.4 KiB
Go
152 lines
5.4 KiB
Go
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)
|
|
}
|
|
}
|