From 44613c47607421f9836cc2db53eb4bef68b3d43e Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Mon, 13 Jul 2026 23:00:19 -0700 Subject: [PATCH] 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. --- internal/web/games.go | 151 ++++++++++++++++++++++ internal/web/games_test.go | 248 +++++++++++++++++++++++++++++++++++++ internal/web/server.go | 8 ++ pete_games_plan.md | 31 +++-- 4 files changed, 430 insertions(+), 8 deletions(-) create mode 100644 internal/web/games.go create mode 100644 internal/web/games_test.go diff --git a/internal/web/games.go b/internal/web/games.go new file mode 100644 index 0000000..6b26900 --- /dev/null +++ b/internal/web/games.go @@ -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) + } +} diff --git a/internal/web/games_test.go b/internal/web/games_test.go new file mode 100644 index 0000000..f95b7bc --- /dev/null +++ b/internal/web/games_test.go @@ -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") + } +} diff --git a/internal/web/server.go b/internal/web/server.go index 91eb69b..fc7fcc4 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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) diff --git a/pete_games_plan.md b/pete_games_plan.md index 9adf95f..ad745e5 100644 --- a/pete_games_plan.md +++ b/pete_games_plan.md @@ -47,23 +47,38 @@ A multi-session build. This section is the handover; read it before anything els their euros are in flight) and come back if the credit fails. Table cap, 30-minute reaper, per-hand audit log with seeds. 17 tests. *(pete `f9a98f7`)* +- **The wire protocol.** Pete serves `GET /api/games/escrow/pending`, `POST …/claim`, + `POST …/settled` (`internal/web/games.go`), bearer-authed on the adventure ingest + token. gogobee polls every 3s (`internal/plugin/pete_games.go`), claims a row, + calls `DebitIdem`/`CreditIdem` against the escrow GUID, and pushes the verdict + back through `pete_emit_queue` — which grew a `path` column so escrow verdicts + ride the same durable queue as adventure facts rather than getting a second one. + `peteclient.Flush` sends the verdict immediately instead of waiting out the 15s + sender tick, because a player is watching a spinner. A row re-offered after a + gogobee crash replays as a no-op: 13 tests across both repos, including a fake + Pete that offers the same row three times and a player who is charged once. + ### Next, in order -1. **Wire protocol.** Pete: `GET /api/games/escrow/pending`, `POST …/claim`, - `POST …/settled`, all bearer-authed like the adventure seam. gogobee: the poll - loop in `internal/peteclient` — its first GET path — calling `DebitIdem`/`CreditIdem` - and pushing verdicts back through the existing `pete_emit_queue`. The storage layer - underneath this is done and tested; only the HTTP and the loop remain. -2. **Identity.** `PreferredUsername` into the OIDC claims struct and the signed cookie; +1. **Identity.** `PreferredUsername` into the OIDC claims struct and the signed cookie; cookie `Domain: ".parodia.dev"` so a news session travels to games. Add the games redirect URI to the `pete` app in Authentik. -3. **Frontend.** Host branching in the mux, lobby + blackjack table, animated dealing. -4. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below. +2. **Frontend.** Host branching in the mux, lobby + blackjack table, animated dealing. + Nothing yet *opens* an escrow row from a browser — `RequestBuyIn`/`RequestCashOut` + have no HTTP surface, on purpose: they need a signed-in Matrix identity, which is + step 1. +3. Then Phase 2 (trivia, hangman), 3 (UNO), 4 (hold'em) as below. ### Notes for whoever picks this up - SQLite runs at `MaxOpenConns(1)` in *both* repos. Any `db.Get().Exec` inside an open transaction deadlocks against itself. Do the pre-work before `Begin`. +- **A buy-in can currently take a player into debt.** `DebitIdem` inherits + `BLACKJACK_DEBT_LIMIT` (default −1000), so someone with an empty wallet can buy + €1,000 of chips, win, and cash out while still €1,000 down. That is exactly what + gogobee's Matrix blackjack already allows, so it is consistent rather than a bug — + but a web casino runs far more hands, and this is the knob to turn if the economy + starts leaking. A buy-in-specific floor of 0 is a two-line change. - gogobee's blackjack taxes 5% of the *gross* payout into a community pot (`communityTax`). Pete's rake takes 5% of the *profit*. Deliberately different, and gentler; don't "fix" one to match the other without deciding which is right.