games: the wire the euros cross

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.
This commit is contained in:
prosolis
2026-07-13 23:00:19 -07:00
parent a442cfccaa
commit 44613c4760
4 changed files with 430 additions and 8 deletions

151
internal/web/games.go Normal file
View File

@@ -0,0 +1,151 @@
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)
}
}

248
internal/web/games_test.go Normal file
View File

@@ -0,0 +1,248 @@
package web
import (
"bytes"
"encoding/json"
"net/http/httptest"
"testing"
"pete/internal/storage"
)
// pollEscrow runs one gogobee poll.
func pollEscrow(t *testing.T, s *Server, token string) ([]storage.Escrow, int) {
t.Helper()
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowPending(w, req)
if w.Code != 200 {
return nil, w.Code
}
var out []storage.Escrow
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("decode pending: %v (body %q)", err, w.Body.String())
}
return out, w.Code
}
func claimEscrow(t *testing.T, s *Server, token, guid string) (storage.Escrow, int) {
t.Helper()
body, _ := json.Marshal(escrowGUID{GUID: guid})
req := httptest.NewRequest("POST", "/api/games/escrow/claim", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowClaim(w, req)
var e storage.Escrow
if w.Code == 200 {
_ = json.Unmarshal(w.Body.Bytes(), &e)
}
return e, w.Code
}
func settleEscrow(t *testing.T, s *Server, token string, v escrowVerdict) (storage.Escrow, int) {
t.Helper()
body, _ := json.Marshal(v)
req := httptest.NewRequest("POST", "/api/games/escrow/settled", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
w := httptest.NewRecorder()
s.handleEscrowSettled(w, req)
var e storage.Escrow
if w.Code == 200 {
_ = json.Unmarshal(w.Body.Bytes(), &e)
}
return e, w.Code
}
// TestEscrowBuyInRoundTrip walks the happy path a player actually takes: ask for
// chips, watch gogobee pick the row up, and have the chips appear only once the
// euros are confirmed gone.
func TestEscrowBuyInRoundTrip(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@reala:parodia.dev"
req, err := storage.RequestBuyIn(user, 500)
if err != nil {
t.Fatal(err)
}
// No chips yet. The euros are still gogobee's.
if st, _ := storage.Chips(user); st.Chips != 0 || st.Pending != 500 {
t.Fatalf("before settle: chips=%d pending=%d, want 0/500", st.Chips, st.Pending)
}
pending, code := pollEscrow(t, s, "tok")
if code != 200 || len(pending) != 1 || pending[0].GUID != req.GUID {
t.Fatalf("poll = %d %+v, want the one buy-in", code, pending)
}
if pending[0].MatrixUser != user || pending[0].Kind != storage.KindBuyIn || pending[0].Amount != 500 {
t.Fatalf("poll row = %+v, want the row we opened", pending[0])
}
claimed, code := claimEscrow(t, s, "tok", req.GUID)
if code != 200 || claimed.State != storage.EscrowClaimed {
t.Fatalf("claim = %d state=%q, want 200/claimed", code, claimed.State)
}
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 1500})
if code != 200 || e.State != storage.EscrowFunded {
t.Fatalf("settle = %d state=%q, want 200/funded", code, e.State)
}
st, _ := storage.Chips(user)
if st.Chips != 500 || st.Pending != 0 {
t.Fatalf("after settle: chips=%d pending=%d, want 500/0", st.Chips, st.Pending)
}
if st.EuroBalance != 1500 {
t.Fatalf("euro balance = %v, want the 1500 gogobee reported", st.EuroBalance)
}
// Settled rows are done. A later poll must not offer it again, or gogobee
// would debit the player a second time.
if pending, _ := pollEscrow(t, s, "tok"); len(pending) != 0 {
t.Fatalf("settled row still pending: %+v", pending)
}
}
// TestEscrowVerdictReplayedCreatesChipsOnce is the property the whole guid
// scheme exists for. gogobee's push queue retries: the same verdict lands twice,
// and only the first one may create chips.
func TestEscrowVerdictReplayedCreatesChipsOnce(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@replay:parodia.dev"
req, err := storage.RequestBuyIn(user, 200)
if err != nil {
t.Fatal(err)
}
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
t.Fatalf("claim = %d", code)
}
for i := 0; i < 3; i++ {
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: req.GUID, OK: true, BalanceAfter: 800})
if code != 200 || e.State != storage.EscrowFunded {
t.Fatalf("settle %d = %d state=%q", i, code, e.State)
}
}
if st, _ := storage.Chips(user); st.Chips != 200 {
t.Fatalf("chips = %d after three deliveries of one verdict, want 200", st.Chips)
}
}
// TestEscrowRejectedBuyInMovesNothing — the player couldn't cover it. gogobee
// took no euros, so Pete must create no chips, and the pending amount has to
// clear or it would eat into the table cap forever.
func TestEscrowRejectedBuyInMovesNothing(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@broke:parodia.dev"
req, err := storage.RequestBuyIn(user, 9000)
if err != nil {
t.Fatal(err)
}
if _, code := claimEscrow(t, s, "tok", req.GUID); code != 200 {
t.Fatalf("claim = %d", code)
}
e, code := settleEscrow(t, s, "tok",
escrowVerdict{GUID: req.GUID, OK: false, Reason: "insufficient_funds", BalanceAfter: 12})
if code != 200 || e.State != storage.EscrowRejected {
t.Fatalf("settle = %d state=%q, want 200/rejected", code, e.State)
}
if e.Reason != "insufficient_funds" {
t.Fatalf("reason = %q, want it carried back for the player to read", e.Reason)
}
st, _ := storage.Chips(user)
if st.Chips != 0 || st.Pending != 0 {
t.Fatalf("after rejection: chips=%d pending=%d, want 0/0", st.Chips, st.Pending)
}
}
// TestEscrowCashOutFailureReturnsChips — gogobee couldn't pay. The chips were
// destroyed the moment the cash-out opened, so if we simply mark it done the
// player's money is gone from both sides. It has to come back.
func TestEscrowCashOutFailureReturnsChips(t *testing.T) {
s, _ := newAdvServer(t, "tok")
const user = "@unlucky:parodia.dev"
buy, err := storage.RequestBuyIn(user, 300)
if err != nil {
t.Fatal(err)
}
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: buy.GUID, OK: true}); code != 200 {
t.Fatalf("fund = %d", code)
}
out, err := storage.RequestCashOut(user, 300)
if err != nil {
t.Fatal(err)
}
if st, _ := storage.Chips(user); st.Chips != 0 {
t.Fatalf("chips during cash-out = %d, want 0 — they must not be bettable in flight", st.Chips)
}
e, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: out.GUID, OK: false, Reason: "ledger_error"})
if code != 200 {
t.Fatalf("settle = %d", code)
}
if e.State != storage.EscrowFunded {
t.Fatalf("state = %q, want funded — a failed cash-out gives the chips back", e.State)
}
if st, _ := storage.Chips(user); st.Chips != 300 {
t.Fatalf("chips = %d after a failed cash-out, want the 300 back", st.Chips)
}
}
// TestEscrowUnknownGUIDIsA400 — a verdict for a row Pete has never heard of.
// gogobee has already moved real euros for it, and no amount of retrying will
// conjure the row, so the answer is the one that parks the row in gogobee's
// queue for a human rather than the one that retries forever.
func TestEscrowUnknownGUIDIsA400(t *testing.T) {
s, _ := newAdvServer(t, "tok")
if _, code := settleEscrow(t, s, "tok", escrowVerdict{GUID: "ghost", OK: true}); code != 400 {
t.Fatalf("settle unknown guid = %d, want 400", code)
}
if _, code := claimEscrow(t, s, "tok", "ghost"); code != 404 {
t.Fatalf("claim unknown guid = %d, want 404", code)
}
}
// TestEscrowNeedsTheBearerToken. These endpoints move money and are reachable on
// the same mux as the public news site.
func TestEscrowNeedsTheBearerToken(t *testing.T) {
s, _ := newAdvServer(t, "tok")
req, err := storage.RequestBuyIn("@x:parodia.dev", 10)
if err != nil {
t.Fatal(err)
}
if _, code := pollEscrow(t, s, "wrong"); code != 401 {
t.Fatalf("poll with a bad token = %d, want 401", code)
}
if _, code := claimEscrow(t, s, "", req.GUID); code != 401 {
t.Fatalf("claim with no token = %d, want 401", code)
}
if _, code := settleEscrow(t, s, "wrong", escrowVerdict{GUID: req.GUID, OK: true}); code != 401 {
t.Fatalf("settle with a bad token = %d, want 401", code)
}
if st, _ := storage.Chips("@x:parodia.dev"); st.Chips != 0 {
t.Fatal("an unauthenticated settle created chips")
}
}
// TestEscrowEmptyPollIsAnArrayNotNull. gogobee decodes into []Escrow; a bare
// `null` body decodes fine in Go but is a trap for anything else that ever reads
// this, and an empty poll is the overwhelmingly common case.
func TestEscrowEmptyPollIsAnArrayNotNull(t *testing.T) {
s, _ := newAdvServer(t, "tok")
req := httptest.NewRequest("GET", "/api/games/escrow/pending", nil)
req.Header.Set("Authorization", "Bearer tok")
w := httptest.NewRecorder()
s.handleEscrowPending(w, req)
if got := w.Body.String(); got != "[]\n" {
t.Fatalf("empty poll body = %q, want %q", got, "[]\n")
}
}

View File

@@ -202,6 +202,14 @@ func New(cfg config.WebConfig, sources []config.SourceConfig, postingEnabled boo
// /adventure/{guid}, so the two patterns never overlap.
mux.HandleFunc("GET /adventure/art/{type}", s.handleAdventureArt)
// The euro/chip border. gogobee polls pending, claims a row, moves the euros,
// and pushes the verdict back. Bearer-authed on the same ingest token as the
// adventure seam, so like it, these sit outside the sign-in block — the caller
// is a machine on the tailnet, not a browser.
mux.HandleFunc("GET /api/games/escrow/pending", s.handleEscrowPending)
mux.HandleFunc("POST /api/games/escrow/claim", s.handleEscrowClaim)
mux.HandleFunc("POST /api/games/escrow/settled", s.handleEscrowSettled)
if s.auth != nil {
mux.HandleFunc("GET /auth/login", s.auth.handleLogin)
mux.HandleFunc("GET /auth/callback", s.auth.handleCallback)